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

431 lines
15 KiB
Go

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)
}
})
}