616 lines
23 KiB
Go
616 lines
23 KiB
Go
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))
|
|
}
|
|
}
|