Files
fm_be/internal/transport/http/handlers/helicopter_handler_test.go
2026-07-16 22:16:45 +07:00

1556 lines
54 KiB
Go

package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
complaint "wucher/internal/domain/complaint"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/domain/helicopter"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/uuidv7"
responsedto "wucher/internal/transport/http/dto/response"
)
func TestApplyHelicopterFleetStatus_GroundedSetsInactive(t *testing.T) {
res := responsedto.HelicopterResource{}
res.Attributes.IsActive = true
row := &helicopter.Helicopter{IsActive: true, AirOnGround: false}
// A non-empty groundReason simulates an open-complaint grounding (not persisted
// in air_on_ground), and is reflected verbatim as the AOG reason.
reason := "Grounded due to an open complaint — MEL A grace period expired"
applyHelicopterFleetStatus(&res, row, false, reason, false, nil)
if res.Attributes.Status != helicopter.StatusAOG {
t.Fatalf("expected status AOG, got %q", res.Attributes.Status)
}
if !res.Attributes.AOG {
t.Fatal("expected aog=true")
}
if res.Attributes.IsActive {
t.Fatal("expected is_active=false for a grounded (AOG) helicopter")
}
if res.Attributes.AOGReason != reason {
t.Fatalf("expected enriched AOG reason %q, got %q", reason, res.Attributes.AOGReason)
}
}
func TestApplyHelicopterFleetStatus_AvailableStaysActive(t *testing.T) {
res := responsedto.HelicopterResource{}
res.Attributes.IsActive = true
row := &helicopter.Helicopter{IsActive: true, AirOnGround: false}
applyHelicopterFleetStatus(&res, row, false, "", false, nil)
if res.Attributes.Status != helicopter.StatusAvailable {
t.Fatalf("expected status available, got %q", res.Attributes.Status)
}
if !res.Attributes.IsActive {
t.Fatal("expected is_active=true for an available helicopter")
}
}
func TestComplaintGroundingReason_MELvsNonMEL(t *testing.T) {
mel := complaint.Complaint{MELSeverity: complaint.MELSeverityA}
if got := complaintGroundingReason(mel); !strings.Contains(got, "MEL A") || !strings.Contains(got, "grace period expired") {
t.Fatalf("unexpected MEL reason: %q", got)
}
nonMEL := complaint.Complaint{MELSeverity: complaint.MELSeverityNonMEL}
if got := complaintGroundingReason(nonMEL); !strings.Contains(got, "non-MEL") {
t.Fatalf("unexpected non-MEL reason: %q", got)
}
}
type memHelicopterRepo struct {
rows map[string]*helicopter.Helicopter
createErr error
updateErr error
deleteErr error
getErr error
listErr error
nextErr error
existsErr error
listRows []helicopter.Helicopter
listTotal int64
}
type helicopterPresignStorageMock struct {
url string
err error
}
type helicopterAttachmentRepoMock struct {
getByIDFn func(id []byte) (*filemanager.Attachment, error)
}
type helicopterPINVerifierMock struct {
consumeFn func(ctx context.Context, userID []byte, action, token string) error
}
type helicopterPINSessionVerifierMock struct {
helicopterPINVerifierMock
accessCookieName string
parseSessionFn func(ctx context.Context, accessToken string) (string, error)
consumeSessionFn func(ctx context.Context, userID []byte, action, token, sessionID string) error
}
func intPtr(v int) *int {
return &v
}
func (m *helicopterAttachmentRepoMock) Create(context.Context, *filemanager.Attachment) error {
return nil
}
func (m *helicopterAttachmentRepoMock) Delete(context.Context, []byte) error {
return nil
}
func (m *helicopterAttachmentRepoMock) GetByID(_ context.Context, id []byte) (*filemanager.Attachment, error) {
if m.getByIDFn != nil {
return m.getByIDFn(id)
}
return nil, nil
}
func (m *helicopterAttachmentRepoMock) GetByRefAndFile(context.Context, string, string, []byte) (*filemanager.Attachment, error) {
return nil, nil
}
func (m *helicopterAttachmentRepoMock) ListByReference(context.Context, filemanager.AttachmentListFilter) ([]filemanager.Attachment, int64, error) {
return nil, 0, nil
}
func (m *helicopterPINVerifierMock) ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error {
if m.consumeFn != nil {
return m.consumeFn(ctx, userID, action, token)
}
return nil
}
func (m *helicopterPINSessionVerifierMock) AccessCookieName() string {
if strings.TrimSpace(m.accessCookieName) == "" {
return "access_token"
}
return m.accessCookieName
}
func (m *helicopterPINSessionVerifierMock) ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) {
if m.parseSessionFn != nil {
return m.parseSessionFn(ctx, accessToken)
}
return "", nil
}
func (m *helicopterPINSessionVerifierMock) ConsumeSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error {
if m.consumeSessionFn != nil {
return m.consumeSessionFn(ctx, userID, action, token, sessionID)
}
return nil
}
func (m *helicopterPresignStorageMock) PresignGetObject(_ context.Context, _ string, _ time.Duration) (string, error) {
if m.err != nil {
return "", m.err
}
return m.url, nil
}
func newMemHelicopterRepo() *memHelicopterRepo {
return &memHelicopterRepo{rows: map[string]*helicopter.Helicopter{}}
}
func (r *memHelicopterRepo) Create(_ context.Context, h *helicopter.Helicopter) error {
if r.createErr != nil {
return r.createErr
}
if len(h.ID) == 0 {
h.ID = uuidv7.MustBytes()
}
now := time.Now().UTC()
if h.CreatedAt.IsZero() {
h.CreatedAt = now
}
h.UpdatedAt = now
clone := *h
r.rows[string(clone.ID)] = &clone
return nil
}
func (r *memHelicopterRepo) Update(_ context.Context, h *helicopter.Helicopter) error {
if r.updateErr != nil {
return r.updateErr
}
if existing, ok := r.rows[string(h.ID)]; ok {
_ = existing
}
h.UpdatedAt = time.Now().UTC()
clone := *h
r.rows[string(clone.ID)] = &clone
return nil
}
func (r *memHelicopterRepo) Delete(_ context.Context, id []byte) error {
if r.deleteErr != nil {
return r.deleteErr
}
delete(r.rows, string(id))
return nil
}
func (r *memHelicopterRepo) GetByID(_ context.Context, id []byte) (*helicopter.Helicopter, error) {
if r.getErr != nil {
return nil, r.getErr
}
h := r.rows[string(id)]
if h == nil {
return nil, nil
}
clone := *h
return &clone, nil
}
func (r *memHelicopterRepo) List(_ context.Context, _ string, _ []string, _ string, _ int, _ int, _ [][]byte) ([]helicopter.Helicopter, int64, error) {
if r.listErr != nil {
return nil, 0, r.listErr
}
if r.listRows != nil {
return r.listRows, r.listTotal, nil
}
out := make([]helicopter.Helicopter, 0, len(r.rows))
for _, row := range r.rows {
out = append(out, *row)
}
return out, int64(len(out)), nil
}
func (r *memHelicopterRepo) NextReportNumber(_ context.Context, id []byte) (string, int, error) {
if r.nextErr != nil {
return "", 0, r.nextErr
}
h := r.rows[string(id)]
if h == nil {
return "", 0, gorm.ErrRecordNotFound
}
h.ReportSequence++
return fmt.Sprintf("%s-%04d", h.Identifier, h.ReportSequence), h.ReportSequence, nil
}
func (r *memHelicopterRepo) ExistsByIdentifier(_ context.Context, identifier string, excludeID []byte) (bool, error) {
if r.existsErr != nil {
return false, r.existsErr
}
target := strings.ToUpper(strings.TrimSpace(identifier))
for _, row := range r.rows {
if len(excludeID) > 0 && string(row.ID) == string(excludeID) {
continue
}
if strings.ToUpper(strings.TrimSpace(row.Identifier)) == target {
return true, nil
}
}
return false, nil
}
func (r *memHelicopterRepo) IsInUse(_ context.Context, id []byte) (bool, error) {
_ = id
return false, nil
}
func (r *memHelicopterRepo) InUseByAircraftIDs(_ context.Context, ids [][]byte) (map[string]bool, error) {
_ = ids
return map[string]bool{}, nil
}
func (r *memHelicopterRepo) ActiveFlightIDByAircraftIDs(_ context.Context, _ [][]byte) (map[string][]byte, error) {
return map[string][]byte{}, nil
}
func TestNewHelicopterHandler(t *testing.T) {
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo()))
if h == nil || h.validate == nil {
t.Fatalf("expected handler and validator initialized")
}
}
func TestHelicopterHandler_Create(t *testing.T) {
t.Run("success", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
actor := uuidv7.MustBytes()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":" OE-XHZ ","identifier":"1234","type":" EC-13 ","sortkey":3,"visible":true}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
if len(repo.rows) != 1 {
t.Fatalf("expected 1 created row, got %d", len(repo.rows))
}
for _, row := range repo.rows {
if row.Designation != "OE-XHZ" {
t.Fatalf("expected designation trimmed, got %q", row.Designation)
}
if row.Identifier != "1234" {
t.Fatalf("expected identifier unchanged, got %q", row.Identifier)
}
if row.ReportSequence != 0 {
t.Fatalf("expected report_sequence initialized to 0, got %d", row.ReportSequence)
}
if row.SortKey == nil || *row.SortKey != 3 {
t.Fatalf("expected sortkey set, got %#v", row.SortKey)
}
if string(row.CreatedBy) != string(actor) {
t.Fatalf("expected created_by from actor")
}
}
})
t.Run("invalid json", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader([]byte(`{"data":`)))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("validation error", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader([]byte(`{"data":{"type":"","attributes":{}}}`)))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("duplicate identifier (case-insensitive) is denied", func(t *testing.T) {
repo := newMemHelicopterRepo()
repo.rows[string(uuidv7.MustBytes())] = &helicopter.Helicopter{
ID: uuidv7.MustBytes(),
Designation: "OLD",
Identifier: "SN-0001",
ReportSequence: 1,
Type: "EC-13",
IsActive: true,
}
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":" OE-NEW ","identifier":"sn-0001","type":" EC-13 "}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Serial number already exists") {
t.Fatalf("expected duplicate serial number message, got %s", string(body))
}
if !strings.Contains(string(body), "/data/attributes/identifier") {
t.Fatalf("expected identifier pointer in error source, got %s", string(body))
}
if len(repo.rows) != 1 {
t.Fatalf("expected no additional row on duplicate identifier")
}
})
t.Run("missing required attributes", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"","identifier":"","type":""}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("create error", func(t *testing.T) {
repo := newMemHelicopterRepo()
repo.createErr = errors.New("db error")
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"1234","type":"EC-13"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if strings.Contains(string(body), "db error") {
t.Fatalf("expected raw error to be hidden, got %s", string(body))
}
if !strings.Contains(string(body), "an internal error occurred") {
t.Fatalf("expected generic internal message, got %s", string(body))
}
})
t.Run("aog true requires reason", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"1234","type":"EC-13","aog":true}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_Update(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234",
ReportSequence: 1,
Type: "EC-13",
IsActive: true,
}
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Patch("/api/v1/helicopters/update/:id", h.Update)
t.Run("success", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-NEW","identifier":"9876","sortkey":7}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
updated := repo.rows[string(id)]
if updated.Designation != "OE-NEW" {
t.Fatalf("expected designation updated, got %q", updated.Designation)
}
if updated.Identifier != "9876" {
t.Fatalf("expected identifier unchanged, got %q", updated.Identifier)
}
if updated.ReportSequence != 1 {
t.Fatalf("expected report_sequence unchanged, got %d", updated.ReportSequence)
}
if updated.SortKey == nil || *updated.SortKey != 7 {
t.Fatalf("expected sortkey updated, got %#v", updated.SortKey)
}
})
t.Run("empty file_uuid clears existing attachment", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
fotoID := uuidv7.MustBytes()
repo.rows[string(id)].FotoAttachmentID = append([]byte(nil), fotoID...)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-KEEP","file_uuid":""}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if repo.rows[string(id)].FotoAttachmentID != nil {
t.Fatalf("expected foto attachment to be cleared")
}
})
t.Run("existing attachment uuid is accepted on update", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
attachmentID := uuidv7.MustBytes()
attachmentIDStr, _ := uuidv7.BytesToString(attachmentID)
fileSvc := service.NewFileManagerService(nil, nil, &helicopterAttachmentRepoMock{
getByIDFn: func(gotID []byte) (*filemanager.Attachment, error) {
if string(gotID) != string(attachmentID) {
return nil, nil
}
return &filemanager.Attachment{ID: append([]byte(nil), attachmentID...)}, nil
},
}, service.FileManagerServiceDependencies{})
hWithFileManager := NewHelicopterHandler(service.NewHelicopterService(repo)).WithFileManagerService(fileSvc)
appWithFileManager := fiber.New()
appWithFileManager.Patch("/api/v1/helicopters/update/:id", hWithFileManager.Update)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-ATTACH","file_uuid":"` + attachmentIDStr + `"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := appWithFileManager.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if string(repo.rows[string(id)].FotoAttachmentID) != string(attachmentID) {
t.Fatalf("expected existing attachment uuid to be accepted")
}
})
t.Run("invalid path uuid", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/bad-uuid", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("invalid json", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader([]byte(`{"data":`)))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("id required", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"","attributes":{"designation":"X"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("id mismatch", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
other, _ := uuidv7.BytesToString(uuidv7.MustBytes())
payload := []byte(`{"data":{"type":"helicopter","id":"` + other + `","attributes":{"designation":"X"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("no attrs", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("not found", func(t *testing.T) {
missing := uuidv7.MustBytes()
missingStr, _ := uuidv7.BytesToString(missing)
payload := []byte(`{"data":{"type":"helicopter","id":"` + missingStr + `","attributes":{"designation":"X"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+missingStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
t.Run("invalid designation", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":" "}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("invalid identifier", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"identifier":" "}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("invalid type", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"type":" "}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("success update all fields", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-ALL","identifier":"4321","type":"EC-145","engine_1":"TM1","engine_2":"TM2","consumption_lt_min":8.8,"nr1":true,"nr2":false,"lh":true,"rh":false,"mgb":true,"igb":false,"tgb":true,"utc_offset":2,"dry":true,"note":"all fields updated","is_active":false}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
updated := repo.rows[string(id)]
if updated.Type != "EC-145" || updated.Engine1 != "TM1" || updated.Engine2 != "TM2" {
t.Fatalf("expected scalar fields updated, got %+v", *updated)
}
if !updated.Dry || updated.IsActive {
t.Fatalf("expected boolean fields updated, got %+v", *updated)
}
if updated.Note != "all fields updated" || updated.Identifier != "4321" {
t.Fatalf("expected note/identifier updated, got %+v", *updated)
}
})
t.Run("update error", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
repo.updateErr = errors.New("update failed")
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-ZZZ"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
repo.updateErr = nil
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("aog true requires reason", func(t *testing.T) {
idNoReason := uuidv7.MustBytes()
repo.rows[string(idNoReason)] = &helicopter.Helicopter{
ID: idNoReason,
Designation: "OE-NOREASON",
Identifier: "2000-0001",
Type: "EC-13",
AirOnGround: false,
AOGReason: "",
IsActive: true,
}
idStr, _ := uuidv7.BytesToString(idNoReason)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"aog":true}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_AOGPINVerification(t *testing.T) {
t.Run("create aog false does not require pin", func(t *testing.T) {
repo := newMemHelicopterRepo()
actor := uuidv7.MustBytes()
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(&helicopterPINVerifierMock{
consumeFn: func(_ context.Context, _ []byte, _ string, _ string) error {
t.Fatalf("expected pin verifier not to be called when aog is false")
return nil
},
})
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-NO-PIN","identifier":"7778","type":"EC-145","aog":false}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
if len(repo.rows) != 1 {
t.Fatalf("expected helicopter created without pin when aog is false")
}
})
t.Run("create aog true requires pin", func(t *testing.T) {
repo := newMemHelicopterRepo()
actor := uuidv7.MustBytes()
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(&helicopterPINVerifierMock{
consumeFn: func(_ context.Context, userID []byte, action, token string) error {
if string(userID) != string(actor) {
t.Fatalf("unexpected actor passed to pin verifier")
}
if action != sharedconst.PinActionHelicopterAOGChange {
t.Fatalf("unexpected pin action %q", action)
}
if token == "" {
return errors.New("missing token")
}
return nil
},
})
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-AOG","identifier":"7777","type":"EC-145","aog":true,"aog_reason":"grounded"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected 202, got %d", resp.StatusCode)
}
if len(repo.rows) != 0 {
t.Fatalf("expected no helicopter created without pin verification")
}
})
t.Run("update aog change requires pin", func(t *testing.T) {
repo := newMemHelicopterRepo()
actor := uuidv7.MustBytes()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234-0001",
Type: "EC-13",
AirOnGround: false,
IsActive: true,
}
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(&helicopterPINVerifierMock{
consumeFn: func(_ context.Context, userID []byte, action, token string) error {
if string(userID) != string(actor) {
t.Fatalf("unexpected actor passed to pin verifier")
}
if action != sharedconst.PinActionHelicopterAOGChange {
t.Fatalf("unexpected pin action %q", action)
}
if token == "" {
return errors.New("missing token")
}
return nil
},
})
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Patch("/api/v1/helicopters/update/:id", h.Update)
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"aog":true,"aog_reason":"grounded"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected 202, got %d", resp.StatusCode)
}
if repo.rows[string(id)].AirOnGround {
t.Fatalf("expected aog state not to change without pin verification")
}
})
t.Run("update without touching aog does not require pin", func(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234-0001",
Type: "EC-13",
AirOnGround: false,
IsActive: true,
}
pinVerifierCalled := false
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(&helicopterPINVerifierMock{
consumeFn: func(_ context.Context, _ []byte, _ string, _ string) error {
pinVerifierCalled = true
return nil
},
})
app := fiber.New()
app.Patch("/api/v1/helicopters/update/:id", h.Update)
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"designation":"OE-UPDATED"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if pinVerifierCalled {
t.Fatalf("expected pin verifier not to be called when aog is untouched")
}
})
t.Run("update aog uses session-bound token when verifier supports it", func(t *testing.T) {
repo := newMemHelicopterRepo()
actor := uuidv7.MustBytes()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234-0001",
Type: "EC-13",
AirOnGround: false,
IsActive: true,
}
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(&helicopterPINSessionVerifierMock{
accessCookieName: "access_token",
parseSessionFn: func(_ context.Context, accessToken string) (string, error) {
if accessToken != "access.jwt.token" {
t.Fatalf("unexpected access token %q", accessToken)
}
return "sid-123", nil
},
consumeSessionFn: func(_ context.Context, userID []byte, action, token, sessionID string) error {
if string(userID) != string(actor) {
t.Fatalf("unexpected actor passed to pin verifier")
}
if action != sharedconst.PinActionHelicopterAOGChange {
t.Fatalf("unexpected pin action %q", action)
}
if token != "pin-token" {
t.Fatalf("unexpected pin token %q", token)
}
if sessionID != "sid-123" {
t.Fatalf("unexpected session id %q", sessionID)
}
return nil
},
})
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Patch("/api/v1/helicopters/update/:id", h.Update)
idStr, _ := uuidv7.BytesToString(id)
payload := []byte(`{"data":{"type":"helicopter","id":"` + idStr + `","attributes":{"aog":true,"aog_reason":"grounded"}}}`)
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/helicopters/update/"+idStr, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(pinVerificationHeader, "pin-token")
req.AddCookie(&http.Cookie{Name: "access_token", Value: "access.jwt.token"})
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if !repo.rows[string(id)].AirOnGround {
t.Fatalf("expected aog state to change after valid session-bound pin verification")
}
})
}
func TestHelicopterHandler_GetWithFoto(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
fotoID := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234-0001",
Type: "EC-13",
FotoAttachmentID: fotoID,
FotoAttachment: &filemanager.Attachment{
ID: fotoID,
FileID: uuidv7.MustBytes(),
File: &filemanager.File{ID: uuidv7.MustBytes(), ObjectKey: "helicopter/oe-xhz.jpg"},
},
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithFileStorage(&helicopterPresignStorageMock{
url: "https://signed.example/helicopter/oe-xhz.jpg?sig=abc",
})
app := fiber.New()
app.Get("/api/v1/helicopters/get/:id", h.Get)
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get/"+idStr, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
fotoIDStr, _ := uuidv7.BytesToString(fotoID)
bodyStr := string(body)
if !strings.Contains(bodyStr, `"uuid":"`+fotoIDStr+`"`) {
t.Fatalf("expected foto uuid in response, body=%s", bodyStr)
}
if !strings.Contains(bodyStr, `"download_url":"https://signed.example/helicopter/oe-xhz.jpg?sig=abc"`) {
t.Fatalf("expected foto download_url in response, body=%s", bodyStr)
}
if !strings.Contains(bodyStr, `"original_size_bytes":0`) {
t.Fatalf("expected foto original_size_bytes in response, body=%s", bodyStr)
}
}
func TestHelicopterHandler_Delete(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{ID: id, Identifier: "1234"}
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Delete("/api/v1/helicopters/delete/:id", h.Delete)
t.Run("success", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/helicopters/delete/"+idStr, nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("invalid uuid", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/helicopters/delete/bad", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("not found", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Delete("/api/v1/helicopters/delete/:id", h.Delete)
id := uuidv7.MustBytes()
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/helicopters/delete/"+idStr, nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
t.Run("delete error", func(t *testing.T) {
repo.deleteErr = errors.New("delete failed")
id2 := uuidv7.MustBytes()
repo.rows[string(id2)] = &helicopter.Helicopter{ID: id2, Identifier: "ERR-DEL"}
id2Str, _ := uuidv7.BytesToString(id2)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/helicopters/delete/"+id2Str, nil)
resp, _ := app.Test(req)
repo.deleteErr = nil
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("delete relation conflict", func(t *testing.T) {
repo.deleteErr = &mysqldriver.MySQLError{
Number: 1451,
Message: "Cannot delete or update a parent row: a foreign key constraint fails",
}
id2 := uuidv7.MustBytes()
repo.rows[string(id2)] = &helicopter.Helicopter{ID: id2, Identifier: "REL-DEL"}
id2Str, _ := uuidv7.BytesToString(id2)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/helicopters/delete/"+id2Str, nil)
resp, _ := app.Test(req)
repo.deleteErr = nil
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, `"code":"HELICOPTER_RELATION_DELETE_ERROR"`) {
t.Fatalf("expected HELICOPTER_RELATION_DELETE_ERROR, got %s", bodyStr)
}
if !strings.Contains(bodyStr, `"error_code":"4090303"`) {
t.Fatalf("expected 4090303, got %s", bodyStr)
}
})
t.Run("identifier lookup error", func(t *testing.T) {
repo := newMemHelicopterRepo()
repo.existsErr = errors.New("lookup failed")
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-ERR","identifier":"SN-ERR","type":"EC-13"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("aog requires reason", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"SN-1","type":"EC-13","aog":true,"aog_reason":""}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("aog requires pin token", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
actor := uuidv7.MustBytes()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"SN-2","type":"EC-13","aog":true,"aog_reason":"maintenance"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
})
t.Run("aog pin verifier rejects token", func(t *testing.T) {
repo := newMemHelicopterRepo()
pin := &helicopterPINVerifierMock{
consumeFn: func(_ context.Context, _ []byte, _ string, _ string) error {
return errors.New("pin required")
},
}
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(pin)
app := fiber.New()
actor := uuidv7.MustBytes()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"SN-3","type":"EC-13","aog":true,"aog_reason":"maintenance"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected 202, got %d", resp.StatusCode)
}
})
t.Run("aog pin verifier accepted continues create", func(t *testing.T) {
repo := newMemHelicopterRepo()
pin := &helicopterPINVerifierMock{
consumeFn: func(_ context.Context, _ []byte, _ string, _ string) error {
return nil
},
}
h := NewHelicopterHandler(service.NewHelicopterService(repo)).WithPINVerifier(pin)
app := fiber.New()
actor := uuidv7.MustBytes()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", actor)
return c.Next()
})
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"SN-4","type":"EC-13","aog":true,"aog_reason":"maintenance"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
})
t.Run("file_uuid invalid returns validation error", func(t *testing.T) {
repo := newMemHelicopterRepo()
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/create", h.Create)
payload := []byte(`{"data":{"type":"helicopter","attributes":{"designation":"OE-XHZ","identifier":"SN-5","type":"EC-13","file_uuid":"bad-uuid"}}}`)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 400/422, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_NextReportNumber(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{ID: id, Identifier: "1234"}
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Post("/api/v1/helicopters/:id/reports/next-number", h.NextReportNumber)
t.Run("success", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/"+idStr+"/reports/next-number", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "1234-0001") {
t.Fatalf("expected report number in response body, got %s", string(body))
}
})
t.Run("invalid uuid", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/bad/reports/next-number", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("not found from generator", func(t *testing.T) {
missing := uuidv7.MustBytes()
missingStr, _ := uuidv7.BytesToString(missing)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/"+missingStr+"/reports/next-number", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
t.Run("generate failed", func(t *testing.T) {
repo.nextErr = errors.New("boom")
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/"+idStr+"/reports/next-number", nil)
resp, _ := app.Test(req)
repo.nextErr = nil
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("not found on post-check", func(t *testing.T) {
repo.nextErr = nil
repo.getErr = errors.New("gone")
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/"+idStr+"/reports/next-number", nil)
resp, _ := app.Test(req)
repo.getErr = nil
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_Get(t *testing.T) {
repo := newMemHelicopterRepo()
id := uuidv7.MustBytes()
repo.rows[string(id)] = &helicopter.Helicopter{ID: id, Designation: "OE-XHZ", Identifier: "1234", Type: "EC-13"}
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Get("/api/v1/helicopters/get/:id", h.Get)
t.Run("success", func(t *testing.T) {
idStr, _ := uuidv7.BytesToString(id)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get/"+idStr, nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("invalid uuid", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get/bad", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
t.Run("not found", func(t *testing.T) {
missing := uuidv7.MustBytes()
missingStr, _ := uuidv7.BytesToString(missing)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get/"+missingStr, nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_List(t *testing.T) {
repo := newMemHelicopterRepo()
repo.listRows = []helicopter.Helicopter{{ID: uuidv7.MustBytes(), Designation: "OE-XHZ", Identifier: "1234", Type: "EC-13", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
repo.listTotal = 1
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Get("/api/v1/helicopters/get-all", h.List)
t.Run("success", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get-all?filter[search]=OE&page[number]=1&page[size]=20", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("list failed", func(t *testing.T) {
repo.listErr = errors.New("list failed")
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get-all", nil)
resp, _ := app.Test(req)
repo.listErr = nil
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
}
func TestHelicopterHandler_ListDatatable(t *testing.T) {
repo := newMemHelicopterRepo()
repo.listRows = []helicopter.Helicopter{{ID: uuidv7.MustBytes(), Designation: "OE-XHZ", Identifier: "1234", Type: "EC-13", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
repo.listTotal = 1
h := NewHelicopterHandler(service.NewHelicopterService(repo))
app := fiber.New()
app.Get("/api/v1/helicopters/get-all/dt", h.ListDatatable)
t.Run("success", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get-all/dt?page=1&limit=10&draw=3&search=OE", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("list failed", func(t *testing.T) {
repo.listErr = errors.New("list failed")
req, _ := http.NewRequest(http.MethodGet, "/api/v1/helicopters/get-all/dt", nil)
resp, _ := app.Test(req)
repo.listErr = nil
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
}
func TestHelicopterResource(t *testing.T) {
id := uuidv7.MustBytes()
row := &helicopter.Helicopter{
ID: id,
Designation: "OE-XHZ",
Identifier: "1234",
Type: "EC-13",
SortKey: intPtr(10),
Engine1: "TM",
Engine2: "TM",
Consumption: 4.1,
NR1: true,
NR2: true,
LH: true,
RH: true,
MGB: true,
IGB: false,
TGB: true,
UTCOffset: -1,
Dry: false,
Note: "primary",
IsActive: true,
CreatedBy: uuidv7.MustBytes(),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
res := helicopterResource(row)
if res.Type != "helicopter" {
t.Fatalf("expected resource type helicopter, got %s", res.Type)
}
if res.Attributes.Identifier != "1234" {
t.Fatalf("expected identifier mapped, got %q", res.Attributes.Identifier)
}
if res.Attributes.SortKey == nil || *res.Attributes.SortKey != 10 {
t.Fatalf("expected sortkey mapped, got %#v", res.Attributes.SortKey)
}
if res.Attributes.IsInUse {
t.Fatalf("expected is_in_use default false")
}
if res.Attributes.CreatedBy == nil || res.Attributes.CreatedBy.ID == "" {
t.Fatalf("expected created_by mapped")
}
if res.Attributes.CreatedAt == "" || res.Attributes.UpdatedAt == "" {
t.Fatalf("expected created_at and updated_at mapped")
}
}
func TestHelicopterHandler_DefaultHelpers(t *testing.T) {
if boolOrDefault(nil, true) != true {
t.Fatalf("expected bool default")
}
vBool := false
if boolOrDefault(&vBool, true) != false {
t.Fatalf("expected bool pointer value")
}
if intOrDefault(nil, 7) != 7 {
t.Fatalf("expected int default")
}
vInt := 12
if intOrDefault(&vInt, 7) != 12 {
t.Fatalf("expected int pointer value")
}
if floatOrDefault(nil, 1.5) != 1.5 {
t.Fatalf("expected float default")
}
vFloat := 9.9
if floatOrDefault(&vFloat, 1.5) != 9.9 {
t.Fatalf("expected float pointer value")
}
if stringOrDefault(nil, "a") != "a" {
t.Fatalf("expected string default")
}
vStr := "b"
if stringOrDefault(&vStr, "a") != "b" {
t.Fatalf("expected string pointer value")
}
}
func TestNormalizeHelicopterIdentifier(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "empty", raw: "", want: ""},
{name: "plain", raw: "1234", want: "1234"},
{name: "trim spaces", raw: " ABC ", want: "ABC"},
{name: "keep free text", raw: "s/n 1234", want: "S/N 1234"},
{name: "keep free text upper", raw: "S/N 1234", want: "S/N 1234"},
{name: "spaces only", raw: " ", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeHelicopterIdentifier(tc.raw)
if got != tc.want {
t.Fatalf("normalizeHelicopterIdentifier(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestHelicopterHandler_HelperBranches(t *testing.T) {
t.Run("with setters return same handler", func(t *testing.T) {
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo()))
if h.WithFileManagerService(nil) != h {
t.Fatalf("expected WithFileManagerService to return same handler")
}
if h.WithFileStorage(nil) != h {
t.Fatalf("expected WithFileStorage to return same handler")
}
})
t.Run("resolve foto attachment via file uuid without file manager writes validation error", func(t *testing.T) {
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo()))
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
raw := "bad-uuid"
_, handled := h.resolveFotoAttachmentID(c, uuidv7.MustBytes(), &raw, "Create failed")
if !handled {
t.Fatalf("expected invalid uuid to be handled")
}
return nil
})
req, _ := http.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("resolve existing foto attachment returns direct attachment id", func(t *testing.T) {
attachmentID := uuidv7.MustBytes()
fileManagerSvc := service.NewFileManagerService(nil, nil, &helicopterAttachmentRepoMock{
getByIDFn: func(id []byte) (*filemanager.Attachment, error) {
return &filemanager.Attachment{ID: id}, nil
},
}, service.FileManagerServiceDependencies{})
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo())).WithFileManagerService(fileManagerSvc)
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
raw := mustUUIDStringHelicopter(attachmentID)
got, handled := h.resolveExistingFotoAttachmentOrFileID(c, uuidv7.MustBytes(), &raw, "Update failed")
if handled {
t.Fatalf("expected existing attachment path to succeed")
}
if string(got) != string(attachmentID) {
t.Fatalf("expected direct attachment id returned")
}
return c.SendStatus(fiber.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/", nil)
if _, err := app.Test(req); err != nil {
t.Fatalf("app.Test: %v", err)
}
})
t.Run("resolve existing foto attachment surfaces repository error", func(t *testing.T) {
fileManagerSvc := service.NewFileManagerService(nil, nil, &helicopterAttachmentRepoMock{
getByIDFn: func([]byte) (*filemanager.Attachment, error) {
return nil, errors.New("attachment lookup failed")
},
}, service.FileManagerServiceDependencies{})
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo())).WithFileManagerService(fileManagerSvc)
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
raw := mustUUIDStringHelicopter(uuidv7.MustBytes())
_, handled := h.resolveExistingFotoAttachmentOrFileID(c, uuidv7.MustBytes(), &raw, "Update failed")
if !handled {
t.Fatalf("expected repository error to be handled")
}
return nil
})
req, _ := http.NewRequest(http.MethodGet, "/", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("require AOG pin verification branches", func(t *testing.T) {
h := NewHelicopterHandler(service.NewHelicopterService(newMemHelicopterRepo()))
app := fiber.New()
app.Get("/missing-config", func(c *fiber.Ctx) error {
if !h.requireAOGPINVerification(c) {
t.Fatalf("expected missing pin verifier to block request")
}
return nil
})
req, _ := http.NewRequest(http.MethodGet, "/missing-config", nil)
resp, _ := app.Test(req)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
h.pinVerifier = &helicopterPINVerifierMock{}
app.Get("/missing-user", func(c *fiber.Ctx) error {
if !h.requireAOGPINVerification(c) {
t.Fatalf("expected missing actor to block request")
}
return nil
})
req, _ = http.NewRequest(http.MethodGet, "/missing-user", nil)
resp, _ = app.Test(req)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_id", uuidv7.MustBytes())
return c.Next()
})
h.pinVerifier = &helicopterPINVerifierMock{
consumeFn: func(context.Context, []byte, string, string) error { return errors.New("missing pin") },
}
app.Get("/pin-required", func(c *fiber.Ctx) error {
if !h.requireAOGPINVerification(c) {
t.Fatalf("expected invalid token to require pin")
}
return nil
})
req, _ = http.NewRequest(http.MethodGet, "/pin-required", nil)
resp, _ = app.Test(req)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected 202, got %d", resp.StatusCode)
}
h.pinVerifier = &helicopterPINVerifierMock{
consumeFn: func(context.Context, []byte, string, string) error { return nil },
}
app.Get("/pin-ok", func(c *fiber.Ctx) error {
if h.requireAOGPINVerification(c) {
t.Fatalf("expected valid token to pass")
}
return c.SendStatus(fiber.StatusOK)
})
req, _ = http.NewRequest(http.MethodGet, "/pin-ok", nil)
req.Header.Set(pinVerificationHeader, "token")
resp, _ = app.Test(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
})
}
func mustUUIDStringHelicopter(id []byte) string {
v, err := uuidv7.BytesToString(id)
if err != nil {
panic(err)
}
return v
}