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 }