package handlers import ( "context" "encoding/json" "io" "net/http" "strings" "testing" "time" "github.com/gofiber/fiber/v2" actionsignoff "wucher/internal/domain/action_signoff" "wucher/internal/domain/complaint" "wucher/internal/shared/pkg/uuidv7" ) type complaintSignoffMock struct { getByComplaintIDsFn func(context.Context, [][]byte) (map[string]*actionsignoff.ActionSignoff, error) unsignByComplaintFn func(context.Context, []byte) (*actionsignoff.ActionSignoff, error) } func (m *complaintSignoffMock) GetByComplaintIDs(ctx context.Context, ids [][]byte) (map[string]*actionsignoff.ActionSignoff, error) { if m.getByComplaintIDsFn != nil { return m.getByComplaintIDsFn(ctx, ids) } return map[string]*actionsignoff.ActionSignoff{}, nil } func (m *complaintSignoffMock) UnsignByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error) { if m.unsignByComplaintFn != nil { return m.unsignByComplaintFn(ctx, complaintID) } return nil, nil } type complaintServiceMock struct { createFn func(context.Context, *complaint.Complaint) error updateFn func(context.Context, *complaint.Complaint) error getByIDFn func(context.Context, []byte) (*complaint.Complaint, error) hasOpenFn func(context.Context, []byte) (bool, error) listFn func(context.Context, complaint.ListFilter, string, int, int) ([]complaint.Complaint, int64, error) listByHelicopterFn func(context.Context, []byte, int, int) ([]complaint.Complaint, int64, error) resolveNamesFn func(context.Context, []complaint.Complaint) (map[string]string, error) } func (m *complaintServiceMock) Create(ctx context.Context, row *complaint.Complaint) error { if m.createFn != nil { return m.createFn(ctx, row) } return nil } func (m *complaintServiceMock) Update(ctx context.Context, row *complaint.Complaint) error { if m.updateFn != nil { return m.updateFn(ctx, row) } return nil } func (m *complaintServiceMock) Delete(context.Context, []byte, []byte) error { return nil } func (m *complaintServiceMock) GetByID(ctx context.Context, id []byte) (*complaint.Complaint, error) { if m.getByIDFn != nil { return m.getByIDFn(ctx, id) } return nil, nil } func (m *complaintServiceMock) ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error) { if m.listByHelicopterFn != nil { return m.listByHelicopterFn(ctx, helicopterID, limit, offset) } return nil, 0, nil } func (m *complaintServiceMock) List(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int) ([]complaint.Complaint, int64, error) { if m.listFn != nil { return m.listFn(ctx, filter, sort, limit, offset) } return nil, 0, nil } func (m *complaintServiceMock) HelicopterHasOpenComplaint(ctx context.Context, helicopterID []byte) (bool, error) { if m.hasOpenFn != nil { return m.hasOpenFn(ctx, helicopterID) } return false, nil } func (m *complaintServiceMock) ActiveGroundingByHelicopterIDs(context.Context, [][]byte) ([]complaint.Complaint, error) { return nil, nil } func (m *complaintServiceMock) OpenByHelicopterIDs(context.Context, [][]byte) ([]complaint.Complaint, error) { return nil, nil } func (m *complaintServiceMock) HelicopterHasUnactionedComplaint(context.Context, []byte) (bool, error) { return false, nil } func (m *complaintServiceMock) MarkFixedByHelicopter(context.Context, []byte, []byte, []byte) error { return nil } func (m *complaintServiceMock) MarkFixedByComplaint(context.Context, []byte, []byte, []byte) error { return nil } func (m *complaintServiceMock) MarkFixedByFlight(context.Context, []byte, []byte, []byte) error { return nil } func (m *complaintServiceMock) ResolveUserNames(ctx context.Context, rows []complaint.Complaint) (map[string]string, error) { if m.resolveNamesFn != nil { return m.resolveNamesFn(ctx, rows) } return map[string]string{}, nil } func newComplaintApp(h *ComplaintHandler, userID []byte) *fiber.App { app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID) return c.Next() }) app.Post("/api/v1/complaints/create", h.Create) app.Get("/api/v1/complaints/get/:id", h.GetByID) app.Patch("/api/v1/complaints/update/:id", h.Update) app.Post("/api/v1/complaints/action-taken/create/:id", h.ActionTaken) app.Patch("/api/v1/complaints/action-taken/update/:id", h.UpdateActionTaken) app.Post("/api/v1/complaints/nsr/sign/:id", h.NSRSign) app.Post("/api/v1/complaints/nsr/unsign/:id", h.NSRUnsign) app.Get("/api/v1/complaints/hold-items/get-all", h.ListHoldItems) app.Get("/api/v1/complaints/hold-items/get-all/dt", h.ListHoldItemsDatatable) return app } func TestComplaintHandler_ListHoldItems_FiltersOpenMEL(t *testing.T) { var got complaint.ListFilter svc := &complaintServiceMock{ listFn: func(_ context.Context, filter complaint.ListFilter, _ string, _, _ int) ([]complaint.Complaint, int64, error) { got = filter return nil, 0, nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) req, _ := http.NewRequest(http.MethodGet, "/api/v1/complaints/hold-items/get-all?date_from=2026-06-01&date_to=2026-06-30&person=Ganahl&search=vibration", nil) resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if !got.HoldItemsOnly { t.Fatalf("expected HoldItemsOnly=true in filter, got %#v", got) } if got.DateFrom != "2026-06-01" || got.DateTo != "2026-06-30" || got.Person != "Ganahl" || got.Search != "vibration" { t.Fatalf("expected Von/bis/person/search filters passed, got %#v", got) } } // Action-taken now only records the corrective action TEXT — signing moved to // POST /action-signoffs/sign (with complaint_id). No sign field, no 422. func TestComplaintHandler_ActionTaken_RecordsTextWithoutSign(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() var recorded *complaint.Complaint svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { if recorded != nil { return recorded, nil } return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, MELClassifiedAt: func() *time.Time { t := time.Now().UTC(); return &t }()}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { if row.ActionTaken == nil || *row.ActionTaken != "tightened mount" { t.Fatalf("unexpected action_taken: %#v", row.ActionTaken) } recorded = row return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) // No `sign` field anymore. body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":"tightened mount"}}}` req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/action-taken/create/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if recorded == nil { t.Fatal("expected update to be called recording the action text") } } // A pending (unclassified) complaint — mel_classified_at NULL — cannot record action // taken until MEL is classified; the gate returns 422 and never touches the row. func TestComplaintHandler_ActionTaken_BlockedWhenMELPending(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { // Pending: no MELClassifiedAt. return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration"}, nil }, updateFn: func(context.Context, *complaint.Complaint) error { t.Fatal("update must not be called while MEL is pending") return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":"tightened mount"}}}` req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/action-taken/create/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusUnprocessableEntity { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("expected 422 (classify MEL first), got %d body=%s", resp.StatusCode, string(bodyBytes)) } } // NSR is its own flow: nsr/sign sets the deferral on a classified complaint; a pending // (unclassified) complaint cannot be deferred and returns 422. func TestComplaintHandler_NSRSign_SetsDeferralWhenClassified(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() classified := time.Now().UTC() var saved *complaint.Complaint svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { if saved != nil { return saved, nil } return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, MELClassifiedAt: &classified}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { if !row.IsNSR || row.NSRDecidedAt == nil { t.Fatalf("expected NSR set with decided_at, got %#v", row) } saved = row return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_nsr_sign","attributes":{"nsr_reason":"deferred under MEL B"}}}` req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/nsr/sign/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(b)) } if saved == nil || !saved.IsNSR { t.Fatal("expected NSR to be recorded") } } func TestComplaintHandler_NSRSign_BlockedWhenMELPending(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { // Pending: no MELClassifiedAt. return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration"}, nil }, updateFn: func(context.Context, *complaint.Complaint) error { t.Fatal("update must not be called: cannot defer a pending complaint") return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_nsr_sign","attributes":{}}}` req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/nsr/sign/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusUnprocessableEntity { b, _ := io.ReadAll(resp.Body) t.Fatalf("expected 422 (classify MEL first), got %d body=%s", resp.StatusCode, string(b)) } } func TestComplaintHandler_NSRUnsign_ClearsDeferral(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() classified := time.Now().UTC() var saved *complaint.Complaint svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { if saved != nil { return saved, nil } return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, MELClassifiedAt: &classified, IsNSR: true, NSRDecidedAt: &classified}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { if row.IsNSR || row.NSRDecidedAt != nil { t.Fatalf("expected NSR cleared, got %#v", row) } saved = row return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/nsr/unsign/"+complaintIDStr, nil) resp, err := app.Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(b)) } if saved == nil || saved.IsNSR { t.Fatal("expected NSR to be cleared") } } func TestComplaintHandler_UpdateActionTaken_RecordsTextWithoutSign(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() var recorded *complaint.Complaint svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { if recorded != nil { return recorded, nil } return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, MELClassifiedAt: func() *time.Time { t := time.Now().UTC(); return &t }()}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { if row.ActionTaken == nil || *row.ActionTaken != "re-torqued mount" { t.Fatalf("unexpected action_taken: %#v", row.ActionTaken) } recorded = row return nil }, } app := newComplaintApp(NewComplaintHandler(svc), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":"re-torqued mount"}}}` req, _ := http.NewRequest(http.MethodPatch, "/api/v1/complaints/action-taken/update/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if recorded == nil { t.Fatal("expected update to be called recording the action text") } } // The complaint response reads its action-sign state from action_signoff (by // complaint_id), not from the (now-unset) complaint.ActionSignedAt/By columns. func TestComplaintHandler_GetByID_ReadsSignFromSignoff(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() signer := uuidv7.MustBytes() signedAt := time.Now().UTC() action := "replaced part" svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, ActionTaken: &action}, nil }, } var lookedUp [][]byte signoffSvc := &complaintSignoffMock{ getByComplaintIDsFn: func(_ context.Context, ids [][]byte) (map[string]*actionsignoff.ActionSignoff, error) { lookedUp = ids return map[string]*actionsignoff.ActionSignoff{ string(complaintID): {ComplaintID: complaintID, HelicopterID: helicopterID, SignedAt: &signedAt, SignedBy: signer}, }, nil }, } app := newComplaintApp(NewComplaintHandler(svc).WithActionSignoffService(signoffSvc), uuidv7.MustBytes()) req, _ := http.NewRequest(http.MethodGet, "/api/v1/complaints/get/"+complaintIDStr, nil) resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if len(lookedUp) != 1 || string(lookedUp[0]) != string(complaintID) { t.Fatalf("expected signoff lookup by complaint id, got %#v", lookedUp) } attrs := struct { ActionSigned bool `json:"action_signed"` ActionSignedAt *string `json:"action_signed_at"` }{} var payload struct { Data struct { Data struct { Attributes json.RawMessage `json:"attributes"` } `json:"data"` } `json:"data"` } if err := json.Unmarshal(bodyBytes, &payload); err != nil { t.Fatalf("bad json: %v body=%s", err, string(bodyBytes)) } if err := json.Unmarshal(payload.Data.Data.Attributes, &attrs); err != nil { t.Fatalf("bad attributes json: %v body=%s", err, string(bodyBytes)) } if !attrs.ActionSigned { t.Fatalf("expected action_signed=true from signoff, got body=%s", string(bodyBytes)) } if attrs.ActionSignedAt == nil { t.Fatalf("expected action_signed_at from signoff, got body=%s", string(bodyBytes)) } } type complaintAOGRecalcMock struct { fn func(context.Context, []byte) error } func (m *complaintAOGRecalcMock) RecalculateAOGByHelicopter(ctx context.Context, helicopterID []byte) error { if m.fn != nil { return m.fn(ctx, helicopterID) } return nil } // Updating a complaint that was already closed reopens its lifecycle: the complaint is // unfixed, its corrective-action sign-off is unsigned, and AOG is recomputed. func TestComplaintHandler_Update_ReopensAndUnsignsWhenClosed(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) helicopterID := uuidv7.MustBytes() easaID := uuidv7.MustBytes() fixedAt := time.Now().UTC() var updatedRow *complaint.Complaint svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { // A fresh CLOSED complaint on each call (fixed by a past EASA release). return &complaint.Complaint{ID: complaintID, HelicopterID: helicopterID, Description: "vibration", MELSeverity: 2, FixedAt: &fixedAt, FixedByEASAID: easaID}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { updatedRow = row return nil }, } var unsignedComplaint []byte signoffSvc := &complaintSignoffMock{ unsignByComplaintFn: func(_ context.Context, cid []byte) (*actionsignoff.ActionSignoff, error) { unsignedComplaint = cid return nil, nil }, } var recalced []byte aog := &complaintAOGRecalcMock{fn: func(_ context.Context, id []byte) error { recalced = id; return nil }} h := NewComplaintHandler(svc).WithActionSignoffService(signoffSvc).WithAOGRecalculator(aog) app := newComplaintApp(h, uuidv7.MustBytes()) body := `{"data":{"type":"complaint_update","id":"` + complaintIDStr + `","attributes":{"description":"vibration + extra work"}}}` req, _ := http.NewRequest(http.MethodPatch, "/api/v1/complaints/update/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if updatedRow == nil { t.Fatal("expected update to be called") } if updatedRow.FixedAt != nil || len(updatedRow.FixedByEASAID) != 0 { t.Fatalf("expected complaint reopened (fixed_at/by cleared), got at=%v by=%x", updatedRow.FixedAt, updatedRow.FixedByEASAID) } if string(unsignedComplaint) != string(complaintID) { t.Fatalf("expected sign-off unsigned for complaint, got %x", unsignedComplaint) } if string(recalced) != string(helicopterID) { t.Fatalf("expected AOG recomputed for helicopter, got %x", recalced) } } func TestComplaintHandler_UpdateActionTaken_ClearWhenNotSigned(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) action := "old action" now := time.Now().UTC() var cleared bool svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { return &complaint.Complaint{ID: complaintID, HelicopterID: uuidv7.MustBytes(), MELSeverity: 2, MELClassifiedAt: &now, ActionTaken: &action}, nil }, updateFn: func(_ context.Context, row *complaint.Complaint) error { if row.ActionTaken != nil { t.Fatalf("expected action_taken cleared to nil, got %v", *row.ActionTaken) } cleared = true return nil }, } // unsigned sign-off → clearing allowed app := newComplaintApp(NewComplaintHandler(svc).WithActionSignoffService(&complaintSignoffMock{}), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":""}}}` req, _ := http.NewRequest(http.MethodPatch, "/api/v1/complaints/action-taken/update/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if !cleared { t.Fatal("expected action_taken to be cleared") } } func TestComplaintHandler_UpdateActionTaken_ClearBlockedWhenSigned(t *testing.T) { complaintID := uuidv7.MustBytes() complaintIDStr, _ := uuidv7.BytesToString(complaintID) action := "old action" now := time.Now().UTC() svc := &complaintServiceMock{ getByIDFn: func(context.Context, []byte) (*complaint.Complaint, error) { return &complaint.Complaint{ID: complaintID, HelicopterID: uuidv7.MustBytes(), MELSeverity: 2, MELClassifiedAt: &now, ActionTaken: &action}, nil }, updateFn: func(context.Context, *complaint.Complaint) error { t.Fatal("must not update when clearing a signed action") return nil }, } signoffSvc := &complaintSignoffMock{ getByComplaintIDsFn: func(_ context.Context, _ [][]byte) (map[string]*actionsignoff.ActionSignoff, error) { return map[string]*actionsignoff.ActionSignoff{ string(complaintID): {ComplaintID: complaintID, SignedAt: &now}, }, nil }, } app := newComplaintApp(NewComplaintHandler(svc).WithActionSignoffService(signoffSvc), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":""}}}` req, _ := http.NewRequest(http.MethodPatch, "/api/v1/complaints/action-taken/update/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d body=%s", resp.StatusCode, string(bodyBytes)) } if !strings.Contains(string(bodyBytes), "COMPLAINT_ACTION_SIGNED_LOCKED") { t.Fatalf("expected COMPLAINT_ACTION_SIGNED_LOCKED, got %s", string(bodyBytes)) } } func TestComplaintHandler_ActionTaken_CreateStillRequiresText(t *testing.T) { complaintIDStr, _ := uuidv7.BytesToString(uuidv7.MustBytes()) app := newComplaintApp(NewComplaintHandler(&complaintServiceMock{}), uuidv7.MustBytes()) body := `{"data":{"type":"complaint_action_taken","attributes":{"action_taken":""}}}` req, _ := http.NewRequest(http.MethodPost, "/api/v1/complaints/action-taken/create/"+complaintIDStr, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusUnprocessableEntity { bodyBytes, _ := io.ReadAll(resp.Body) t.Fatalf("create with empty action_taken must be 422, got %d body=%s", resp.StatusCode, string(bodyBytes)) } }