package handlers import ( "bytes" "context" "encoding/json" "io" "net/http" "testing" "time" "github.com/gofiber/fiber/v2" "wucher/internal/domain/mcf" "wucher/internal/shared/pkg/uuidv7" ) type stubMCFSvc struct { created *mcf.MaintenanceCheckFlight updated *mcf.MaintenanceCheckFlight latest *mcf.MaintenanceCheckFlight byID *mcf.MaintenanceCheckFlight createErr error updateErr error deleteErr error listErr error latestErr error hasOpen bool } type stubHistoryRec struct{ n int } func (s *stubHistoryRec) Record(_ context.Context, _ []byte, _ string, _ []byte, _, _ string, _ []byte) error { s.n++ return nil } func (s *stubHistoryRec) RecordByInspection(_ context.Context, _ []byte, _ string, _ []byte, _, _ string, _ []byte) error { return nil } func (s *stubMCFSvc) Create(_ context.Context, row *mcf.MaintenanceCheckFlight) error { if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } s.created = row return s.createErr } func (s *stubMCFSvc) Update(_ context.Context, row *mcf.MaintenanceCheckFlight) error { s.updated = row return s.updateErr } func (s *stubMCFSvc) Delete(_ context.Context, _, _ []byte) error { return s.deleteErr } func (s *stubMCFSvc) GetByID(_ context.Context, _ []byte) (*mcf.MaintenanceCheckFlight, error) { if s.byID != nil { return s.byID, nil } if s.updated != nil { return s.updated, nil } return s.created, nil } func (s *stubMCFSvc) ListByHelicopter(_ context.Context, _ []byte) ([]mcf.MaintenanceCheckFlight, error) { if s.listErr != nil { return nil, s.listErr } if s.latest != nil { return []mcf.MaintenanceCheckFlight{*s.latest}, nil } return nil, nil } func (s *stubMCFSvc) GetLatestByHelicopter(_ context.Context, _ []byte) (*mcf.MaintenanceCheckFlight, error) { return s.latest, s.latestErr } func (s *stubMCFSvc) PendingHelicopterIDs(_ context.Context, _ [][]byte) (map[string]bool, error) { return nil, nil } func (s *stubMCFSvc) LatestByHelicopterIDs(_ context.Context, _ [][]byte) (map[string]*mcf.MaintenanceCheckFlight, error) { return nil, nil } func (s *stubMCFSvc) HelicopterHasOpenMCF(_ context.Context, _ []byte) (bool, error) { return s.hasOpen, nil } func newMCFApp(svc mcf.Service) *fiber.App { h := NewMCFHandler(svc) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("actor0123456789a")) return c.Next() }) app.Post("/mcf/set/:helicopter_id", h.Set) app.Post("/mcf/sign/:id", h.Sign) app.Get("/mcf/get/:id", h.GetByID) app.Get("/mcf/get-by-helicopter/:helicopter_id", h.ListByHelicopter) return app } func doMCF(t *testing.T, app *fiber.App, method, path, body string) (int, map[string]any) { t.Helper() req, _ := http.NewRequest(method, path, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } raw, _ := io.ReadAll(resp.Body) var out map[string]any _ = json.Unmarshal(raw, &out) return resp.StatusCode, out } func mcfUUID() string { s, _ := uuidv7.BytesToString(uuidv7.MustBytes()) return s } func openMCF() *mcf.MaintenanceCheckFlight { return &mcf.MaintenanceCheckFlight{ID: uuidv7.MustBytes(), HelicopterID: uuidv7.MustBytes()} } func TestMCFHandlerSet(t *testing.T) { hid := mcfUUID() t.Run("activate creates a draft", func(t *testing.T) { svc := &stubMCFSvc{} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":true}}}`) if code != http.StatusCreated { t.Fatalf("want 201, got %d", code) } if svc.created == nil || len(svc.created.HelicopterID) == 0 { t.Fatal("activate should persist a draft with helicopter id") } if svc.created.Result != nil || svc.created.CompletedAt != nil { t.Fatal("a draft must not be completed/signed") } }) t.Run("omitted active defaults to activate", func(t *testing.T) { svc := &stubMCFSvc{} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{}}}`) if code != http.StatusCreated || svc.created == nil { t.Fatalf("omitted active should create a draft, got %d %#v", code, svc.created) } }) t.Run("activate with flight_id stores it", func(t *testing.T) { svc := &stubMCFSvc{} fid := mcfUUID() code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":true,"flight_id":"`+fid+`"}}}`) if code != http.StatusCreated || svc.created == nil || len(svc.created.FlightID) != 16 { t.Fatalf("activate should persist flight id, got %d %#v", code, svc.created) } }) t.Run("activate updates the open draft (in place)", func(t *testing.T) { draft := &mcf.MaintenanceCheckFlight{ID: uuidv7.MustBytes(), HelicopterID: uuidv7.MustBytes()} // open draft svc := &stubMCFSvc{latest: draft} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":true,"af_hours":"1245.3","landing_count":3}}}`) if code != http.StatusOK { t.Fatalf("want 200 (update open draft), got %d", code) } if svc.updated == nil || svc.updated.AFHours != "1245.3" || svc.updated.LandingCount != 3 { t.Fatalf("activate should update the open draft with the data, got %#v", svc.updated) } if svc.updated.CompletedAt != nil { t.Fatalf("updating a draft must not sign it, got %#v", svc.updated) } if svc.created != nil { t.Fatal("must not create a second MCF when a draft is already open") } }) t.Run("deactivate cancels the open draft", func(t *testing.T) { draft := &mcf.MaintenanceCheckFlight{ID: uuidv7.MustBytes(), HelicopterID: uuidv7.MustBytes()} // open draft svc := &stubMCFSvc{latest: draft} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":false}}}`) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } if svc.updated == nil || svc.updated.CancelledAt == nil || len(svc.updated.CancelledBy) == 0 { t.Fatalf("deactivate must mark the draft cancelled, got %#v", svc.updated) } }) t.Run("deactivate does not cancel a signed MCF", func(t *testing.T) { done := time.Now().UTC() signed := &mcf.MaintenanceCheckFlight{ID: uuidv7.MustBytes(), HelicopterID: uuidv7.MustBytes(), CompletedAt: &done} svc := &stubMCFSvc{latest: signed} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":false}}}`) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } if svc.updated != nil { t.Fatalf("a signed MCF (real event) must not be cancelled, got %#v", svc.updated) } }) t.Run("deactivate no-op when nothing open", func(t *testing.T) { svc := &stubMCFSvc{latest: nil} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":false}}}`) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } if svc.updated != nil { t.Fatal("no open MCF -> nothing to cancel") } }) t.Run("invalid helicopter_id path -> 422", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodPost, "/mcf/set/not-a-uuid", `{"data":{"type":"mcf_set","attributes":{"active":true}}}`) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("invalid json -> 400", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodPost, "/mcf/set/"+hid, `{"data":`) if code != http.StatusBadRequest { t.Fatalf("want 400, got %d", code) } }) t.Run("wrong data type -> INVALID_DATA_TYPE", func(t *testing.T) { // flight_id is a string; sending a number is a data-type mismatch. code, body := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodPost, "/mcf/set/"+hid, `{"data":{"type":"mcf_set","attributes":{"active":true,"flight_id":123}}}`) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d body=%#v", code, body) } errs, _ := body["errors"].([]any) if len(errs) == 0 || errs[0].(map[string]any)["code"] != "INVALID_DATA_TYPE" { t.Fatalf("expected INVALID_DATA_TYPE, got %#v", body) } }) } func completeBody(result string) string { return `{"data":{"type":"mcf","attributes":{"af_hours":"1245.3","landing_count":3,"utc_time":"2026-06-17T08:30:00Z","date":"2026-06-17","result":"` + result + `"}}}` } func TestMCFHandlerSign(t *testing.T) { id := mcfUUID() t.Run("sign passed clears", func(t *testing.T) { svc := &stubMCFSvc{byID: openMCF()} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/sign/"+id, completeBody("passed")) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } if svc.updated == nil || svc.updated.Result == nil || *svc.updated.Result != mcf.ResultPassed { t.Fatal("expected result=passed persisted") } if svc.updated.CompletedAt == nil || svc.updated.CompletedBy == nil || svc.updated.Date == nil || svc.updated.LandingCount != 3 { t.Fatal("expected check-flight fields + signer stamped") } }) t.Run("sign failed stays grounded", func(t *testing.T) { svc := &stubMCFSvc{byID: openMCF()} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/sign/"+id, completeBody("failed")) if code != http.StatusOK || svc.updated == nil || *svc.updated.Result != mcf.ResultFailed { t.Fatalf("want 200 with failed result, got %d", code) } }) t.Run("unsign reverts to draft but keeps data", func(t *testing.T) { passed := mcf.ResultPassed now := time.Now().UTC() signed := openMCF() signed.Result = &passed signed.AFHours = "1300" signed.CompletedAt = &now signed.CompletedBy = []byte("actor0123456789a") svc := &stubMCFSvc{byID: signed} code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/sign/"+id, `{"data":{"type":"mcf","attributes":{"sign":false}}}`) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } // completed_at cleared (back to draft), but the saved data is kept. if svc.updated == nil || svc.updated.CompletedAt != nil || len(svc.updated.CompletedBy) != 0 { t.Fatalf("unsign must clear completion, got %#v", svc.updated) } if svc.updated.Result == nil || *svc.updated.Result != mcf.ResultPassed || svc.updated.AFHours != "1300" { t.Fatalf("unsign must KEEP the saved data, got %#v", svc.updated) } }) t.Run("sign on missing mcf -> 404", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodPost, "/mcf/sign/"+id, completeBody("passed")) if code != http.StatusNotFound { t.Fatalf("want 404, got %d", code) } }) t.Run("sign missing required field -> 422", func(t *testing.T) { body := `{"data":{"type":"mcf","attributes":{"sign":true,"landing_count":3,"utc_time":"2026-06-17T08:30:00Z","date":"2026-06-17","result":"passed"}}}` // no af_hours code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF()}), http.MethodPost, "/mcf/sign/"+id, body) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("sign invalid result -> 422", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF()}), http.MethodPost, "/mcf/sign/"+id, completeBody("maybe")) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("sign bad date -> 422", func(t *testing.T) { body := `{"data":{"type":"mcf","attributes":{"af_hours":"1","landing_count":1,"utc_time":"2026-06-17T08:30:00Z","date":"17-06-2026","result":"passed"}}}` code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF()}), http.MethodPost, "/mcf/sign/"+id, body) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("sign bad utc_time -> 422", func(t *testing.T) { body := `{"data":{"type":"mcf","attributes":{"af_hours":"1","landing_count":1,"utc_time":"nope","date":"2026-06-17","result":"passed"}}}` code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF()}), http.MethodPost, "/mcf/sign/"+id, body) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("invalid id path -> 422", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodPost, "/mcf/sign/not-a-uuid", completeBody("passed")) if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) } func TestMCFHandlerGetListDelete(t *testing.T) { id := mcfUUID() hid := mcfUUID() t.Run("get by id", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF()}), http.MethodGet, "/mcf/get/"+id, "") if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } }) t.Run("get not found", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodGet, "/mcf/get/"+id, "") if code != http.StatusNotFound { t.Fatalf("want 404, got %d", code) } }) t.Run("get invalid uuid", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{}), http.MethodGet, "/mcf/get/bad", "") if code != http.StatusUnprocessableEntity { t.Fatalf("want 422, got %d", code) } }) t.Run("list by helicopter", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{latest: openMCF()}), http.MethodGet, "/mcf/get-by-helicopter/"+hid, "") if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } }) t.Run("list error", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{listErr: context.DeadlineExceeded}), http.MethodGet, "/mcf/get-by-helicopter/"+hid, "") if code != http.StatusBadRequest { t.Fatalf("want 400, got %d", code) } }) } func TestMCFHandlerErrorAndHistoryBranches(t *testing.T) { t.Run("activate service error -> 400", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{createErr: context.DeadlineExceeded}), http.MethodPost, "/mcf/set/"+mcfUUID(), `{"data":{"type":"mcf_set","attributes":{"active":true}}}`) if code != http.StatusBadRequest { t.Fatalf("want 400, got %d", code) } }) t.Run("activate not-found maps to 404", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{createErr: mcf.ErrNotFound}), http.MethodPost, "/mcf/set/"+mcfUUID(), `{"data":{"type":"mcf_set","attributes":{"active":true}}}`) if code != http.StatusNotFound { t.Fatalf("want 404, got %d", code) } }) t.Run("sign update error -> 400", func(t *testing.T) { code, _ := doMCF(t, newMCFApp(&stubMCFSvc{byID: openMCF(), updateErr: context.DeadlineExceeded}), http.MethodPost, "/mcf/sign/"+mcfUUID(), completeBody("passed")) if code != http.StatusBadRequest { t.Fatalf("want 400, got %d", code) } }) t.Run("sign with notes", func(t *testing.T) { svc := &stubMCFSvc{byID: openMCF()} body := `{"data":{"type":"mcf","attributes":{"af_hours":"1","landing_count":2,"utc_time":"2026-06-17T08:30:00Z","date":"2026-06-17","result":"passed","notes":"vibration ok"}}}` code, _ := doMCF(t, newMCFApp(svc), http.MethodPost, "/mcf/sign/"+mcfUUID(), body) if code != http.StatusOK || svc.updated == nil || svc.updated.Notes == nil || *svc.updated.Notes != "vibration ok" { t.Fatalf("want 200 with notes recorded, got %d", code) } }) t.Run("sign records history", func(t *testing.T) { svc := &stubMCFSvc{byID: openMCF()} hist := &stubHistoryRec{} h := NewMCFHandler(svc).WithHistoryService(hist) app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("actor0123456789a")); return c.Next() }) app.Post("/mcf/sign/:id", h.Sign) code, _ := doMCF(t, app, http.MethodPost, "/mcf/sign/"+mcfUUID(), completeBody("failed")) if code != http.StatusOK { t.Fatalf("want 200, got %d", code) } if hist.n != 1 { t.Fatalf("expected 1 history record, got %d", hist.n) } }) }