431 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|