package handlers import ( "bytes" "context" "encoding/hex" "encoding/json" "errors" "io" "net/http" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/valyala/fasthttp" "wucher/internal/domain/base" "wucher/internal/domain/contact" "wucher/internal/domain/dul" filemanager "wucher/internal/domain/file_manager" filemanagerrealtime "wucher/internal/realtime/filemanager" "wucher/internal/shared/pkg/uuidv7" ) type dulServiceMock struct { createFn func(context.Context, *dul.DUL) error createErr error updateErr error deleteErr error getErr error listErr error getResult *dul.DUL listRows []dul.DUL listTotal int64 lastListID [][]byte } func (m *dulServiceMock) Create(ctx context.Context, row *dul.DUL) error { if m.createFn != nil { return m.createFn(ctx, row) } if m.createErr != nil { return m.createErr } if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } return nil } func (m *dulServiceMock) Update(_ context.Context, _ *dul.DUL) error { return m.updateErr } func (m *dulServiceMock) Delete(_ context.Context, _ []byte, _ []byte) error { return m.deleteErr } func (m *dulServiceMock) GetByID(_ context.Context, _ []byte) (*dul.DUL, error) { if m.getErr != nil { return nil, m.getErr } return m.getResult, nil } func (m *dulServiceMock) List(_ context.Context, _ string, _ string, _ int, _ int, baseIDs [][]byte) ([]dul.DUL, int64, error) { m.lastListID = baseIDs if m.listErr != nil { return nil, 0, m.listErr } return m.listRows, m.listTotal, nil } type baseServiceMock struct { listRows []base.Base listErr error } func (m *baseServiceMock) CreateBase(_ context.Context, _ *base.Base, _ string) error { return nil } func (m *baseServiceMock) UpdateBase(_ context.Context, _ *base.Base, _ string) error { return nil } func (m *baseServiceMock) DeleteBase(_ context.Context, _ []byte, _ string) (*base.Base, error) { return nil, nil } func (m *baseServiceMock) GetBaseByID(_ context.Context, _ []byte, _ string) (*base.Base, error) { return nil, nil } func (m *baseServiceMock) ListBases(_ context.Context, _, _ string, _, _ int, _ string, _ *bool) ([]base.Base, int64, error) { if m.listErr != nil { return nil, 0, m.listErr } return m.listRows, int64(len(m.listRows)), nil } type contactServiceMock struct { isAdmin bool adminErr error item *contact.ContactListItem getErr error } func (m *contactServiceMock) ListContacts(_ context.Context, _ contact.ListFilter) ([]contact.ContactListItem, int64, error) { return nil, 0, nil } func (m *contactServiceMock) GetContactByID(_ context.Context, _ []byte) (*contact.ContactListItem, error) { return m.item, m.getErr } func (m *contactServiceMock) IsActorAdmin(_ context.Context) (bool, error) { return m.isAdmin, m.adminErr } func (m *contactServiceMock) ResolveRoleCodeByID(_ context.Context, _ []byte) (string, error) { return "", nil } func (m *contactServiceMock) CreateContact(_ context.Context, _ contact.CreateInput) ([]byte, error) { return nil, nil } func (m *contactServiceMock) UpdateContact(_ context.Context, _ contact.UpdateInput) error { return nil } func (m *contactServiceMock) DeleteContact(_ context.Context, _ []byte) error { return nil } func (m *contactServiceMock) UpdateContactStatus(_ context.Context, _ []byte, _ bool) error { return nil } func (m *contactServiceMock) UpdateContactRole(_ context.Context, _ []byte, _ string) error { return nil } func (m *contactServiceMock) UpdatePilotCategory(_ context.Context, _ []byte, _ string) error { return nil } func (m *contactServiceMock) UpdateLicense(_ context.Context, _ []byte, _ *string) error { return nil } func (m *contactServiceMock) UpdateChiefFlags(_ context.Context, _ []byte, _ contact.ProfilePatch) error { return nil } func (m *contactServiceMock) UpdateResponsibleComplaints(_ context.Context, _ []byte, _ bool) error { return nil } func (m *contactServiceMock) BulkUpdateStatus(_ context.Context, _ [][]byte, _ bool) (int64, error) { return 0, nil } func (m *contactServiceMock) CheckSensitiveAction(_ context.Context, _ []byte, _, _ string) error { return nil } func newDULApp(h *DULHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/dul/create", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.Create(c) }) app.Patch("/api/v1/dul/update/:id", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.Update(c) }) app.Delete("/api/v1/dul/delete/:id", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.Delete(c) }) app.Get("/api/v1/dul/get/:id", h.Get) app.Get("/api/v1/dul/get-all", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.List(c) }) app.Get("/api/v1/dul/get-all/dt", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.ListDatatable(c) }) return app } func newDULEventsApp(h *FileManagerEventsHandler, actor []byte) *fiber.App { app := fiber.New() app.Use(func(c *fiber.Ctx) error { if actor != nil { c.Locals("user_id", append([]byte(nil), actor...)) } return c.Next() }) app.Get("/api/v1/dul/events", h.Stream) return app } func doReqDUL(t *testing.T, app *fiber.App, method, path string, body []byte) (*http.Response, map[string]any) { t.Helper() req, err := http.NewRequest(method, path, bytes.NewReader(body)) if err != nil { t.Fatalf("new request: %v", err) } 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) out := map[string]any{} _ = json.Unmarshal(raw, &out) return resp, out } func TestDULHandler_CRUDAndList(t *testing.T) { baseID := uuidv7.MustBytes() baseIDStr, _ := uuidv7.BytesToString(baseID) dulID := uuidv7.MustBytes() dulIDStr, _ := uuidv7.BytesToString(dulID) svc := &dulServiceMock{ getResult: &dul.DUL{ ID: dulID, BaseID: baseID, Name: "DUL A", Date: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), No: 1, }, listRows: []dul.DUL{{ ID: dulID, BaseID: baseID, Name: "DUL A", Date: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), No: 1, }}, listTotal: 1, } bsvc := &baseServiceMock{ listRows: []base.Base{{ ID: baseID, BaseName: "Halim", DUL: true, BaseCategory: base.BaseCategory{ Name: "hems", }, }}, } h := NewDULHandler(svc).WithBaseService(bsvc) app := newDULApp(h) createBody := []byte(`{"data":{"type":"dul_create","attributes":{"name":"DUL A","date":"2026-05-25","base_id":"` + baseIDStr + `"}}}`) resp, _ := doReqDUL(t, app, http.MethodPost, "/api/v1/dul/create", createBody) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } updateBody := []byte(`{"data":{"type":"dul_update","attributes":{"name":"DUL B"}}}`) resp, _ = doReqDUL(t, app, http.MethodPatch, "/api/v1/dul/update/"+dulIDStr, updateBody) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } resp, _ = doReqDUL(t, app, http.MethodGet, "/api/v1/dul/get/"+dulIDStr, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 get, got %d", resp.StatusCode) } resp, body := doReqDUL(t, app, http.MethodGet, "/api/v1/dul/get-all?page=1&size=20", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 list, got %d", resp.StatusCode) } meta, _ := body["meta"].(map[string]any) if meta == nil || meta["total_bases"] == nil { t.Fatalf("expected meta total_bases") } resp, body = doReqDUL(t, app, http.MethodGet, "/api/v1/dul/get-all/dt?page=1&size=20&draw=1", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 dt, got %d", resp.StatusCode) } meta, _ = body["meta"].(map[string]any) if meta == nil || meta["records_total"] == nil || meta["page_number"] == nil { t.Fatalf("expected datatable meta fields") } resp, _ = doReqDUL(t, app, http.MethodDelete, "/api/v1/dul/delete/"+dulIDStr, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 delete, got %d", resp.StatusCode) } } func TestDULHandler_CreateUpdate_FileUUIDFlow(t *testing.T) { baseID := uuidv7.MustBytes() baseIDStr, _ := uuidv7.BytesToString(baseID) dulID := uuidv7.MustBytes() dulIDStr, _ := uuidv7.BytesToString(dulID) fileID1 := uuidv7.MustBytes() fileID1Str, _ := uuidv7.BytesToString(fileID1) fileID2 := uuidv7.MustBytes() fileID2Str, _ := uuidv7.BytesToString(fileID2) svc := &dulServiceMock{ getResult: &dul.DUL{ ID: dulID, BaseID: baseID, Name: "DUL A", Date: time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), No: 1, }, } createCalls := make([]filemanager.CreateAttachmentParams, 0, 2) mockFM := &fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(_ context.Context, _ []byte) (*filemanager.Attachment, error) { return nil, nil }, getFileFn: func(_ context.Context, id []byte) (*filemanager.File, error) { return &filemanager.File{ID: append([]byte(nil), id...), Status: filemanager.FileStatusReady}, nil }, listAttachmentsFn: func(context.Context, filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { return nil, 0, nil }, createAttachmentFn: func(_ context.Context, p filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { createCalls = append(createCalls, p) return &filemanager.Attachment{ID: uuidv7.MustBytes(), FileID: append([]byte(nil), p.FileID...)}, nil }, } h := NewDULHandler(svc).WithFileManagerService(mockFM) app := newDULApp(h) t.Run("create accepts file_uuid", func(t *testing.T) { body := []byte(`{"data":{"type":"dul_create","attributes":{"name":"DUL A","date":"2026-05-26","base_id":"` + baseIDStr + `","file_uuid":["` + fileID1Str + `"]}}}`) resp, _ := doReqDUL(t, app, http.MethodPost, "/api/v1/dul/create", body) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if len(createCalls) != 1 { t.Fatalf("expected 1 create attachment call, got %d", len(createCalls)) } if !bytes.Equal(createCalls[0].FileID, fileID1) { t.Fatalf("expected first attachment file id = file_uuid[0]") } }) t.Run("update accepts file_uuid", func(t *testing.T) { body := []byte(`{"data":{"type":"dul_update","attributes":{"file_uuid":["` + fileID2Str + `"]}}}`) resp, _ := doReqDUL(t, app, http.MethodPatch, "/api/v1/dul/update/"+dulIDStr, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(createCalls) != 2 { t.Fatalf("expected 2 create attachment calls after update, got %d", len(createCalls)) } if !bytes.Equal(createCalls[1].FileID, fileID2) { t.Fatalf("expected second attachment file id = file_uuid[0]") } }) t.Run("update still supports image_attachment_ids for backward compatibility", func(t *testing.T) { fileID3 := uuidv7.MustBytes() fileID3Str, _ := uuidv7.BytesToString(fileID3) body := []byte(`{"data":{"type":"dul_update","attributes":{"image_attachment_ids":["` + fileID3Str + `"]}}}`) resp, _ := doReqDUL(t, app, http.MethodPatch, "/api/v1/dul/update/"+dulIDStr, body) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if len(createCalls) != 3 { t.Fatalf("expected 3 create attachment calls after backward-compatible update, got %d", len(createCalls)) } if !bytes.Equal(createCalls[2].FileID, fileID3) { t.Fatalf("expected third attachment file id from image_attachment_ids fallback") } }) } func TestDULHandler_CreateRejectsNotReadyFileBeforeCreatingRow(t *testing.T) { baseID := uuidv7.MustBytes() baseIDStr, _ := uuidv7.BytesToString(baseID) fileID := uuidv7.MustBytes() fileIDStr, _ := uuidv7.BytesToString(fileID) created := false svc := &dulServiceMock{ createFn: func(context.Context, *dul.DUL) error { created = true t.Fatal("DUL create must not be called when file is not ready") return nil }, } mockFM := &fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) { return nil, nil }, getFileFn: func(_ context.Context, id []byte) (*filemanager.File, error) { if !bytes.Equal(id, fileID) { t.Fatalf("unexpected file id: %x", id) } return &filemanager.File{ID: append([]byte(nil), id...), Status: filemanager.FileStatusProcessing}, nil }, } h := NewDULHandler(svc).WithFileManagerService(mockFM) app := newDULApp(h) body := []byte(`{"data":{"type":"dul_create","attributes":{"name":"DUL A","date":"2026-05-27","base_id":"` + baseIDStr + `","file_uuid":["` + fileIDStr + `"]}}}`) resp, respBody := doReqDUL(t, app, http.MethodPost, "/api/v1/dul/create", body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for not-ready file, got %d body=%v", resp.StatusCode, respBody) } if created { t.Fatal("expected DUL create to be skipped") } } func TestDULHandler_ListScope_AdminVsPilot(t *testing.T) { baseA := uuidv7.MustBytes() baseB := uuidv7.MustBytes() baseAHex := stringsLower(hex.EncodeToString(baseA)) baseANameHex := stringsLower(hex.EncodeToString([]byte("Base A"))) svc := &dulServiceMock{ listRows: []dul.DUL{}, listTotal: 0, } bsvc := &baseServiceMock{ listRows: []base.Base{ {ID: baseA, BaseName: "Base A", DUL: true, BaseCategory: base.BaseCategory{Name: "hems"}}, {ID: baseB, BaseName: "Base B", DUL: true, BaseCategory: base.BaseCategory{Name: "regular"}}, }, } // Admin => scoped to all bases where dul=true. hAdmin := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: true}) app := fiber.New() app.Get("/list", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return hAdmin.List(c) }) req, _ := http.NewRequest(http.MethodGet, "/list", nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("admin list status=%d", resp.StatusCode) } if len(svc.lastListID) != 2 { t.Fatalf("admin should see all dul bases, got scope size=%d", len(svc.lastListID)) } // Pilot => scoped only to DUL base roles. raw := baseAHex + "|" + baseANameHex + "|dul|" + stringsLower(hex.EncodeToString([]byte("hems"))) contactItem := &contact.ContactListItem{ RoleCode: "pilot", BaseRolesRaw: &raw, } hPilot := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: false, item: contactItem}) app2 := fiber.New() app2.Get("/list", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return hPilot.List(c) }) req2, _ := http.NewRequest(http.MethodGet, "/list", nil) resp2, _ := app2.Test(req2) if resp2.StatusCode != http.StatusOK { t.Fatalf("pilot list status=%d", resp2.StatusCode) } if len(svc.lastListID) != 1 { t.Fatalf("pilot should be scoped to one base, got %d", len(svc.lastListID)) } } func TestDULHandler_ListScope_Rules(t *testing.T) { baseA := uuidv7.MustBytes() baseB := uuidv7.MustBytes() baseC := uuidv7.MustBytes() baseAHex := stringsLower(hex.EncodeToString(baseA)) baseANameHex := stringsLower(hex.EncodeToString([]byte("Base A"))) baseBHex := stringsLower(hex.EncodeToString(baseB)) baseBNameHex := stringsLower(hex.EncodeToString([]byte("Base B"))) makeApp := func(h *DULHandler) *fiber.App { app := fiber.New() app.Get("/list", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) return h.List(c) }) return app } newSvc := func() *dulServiceMock { return &dulServiceMock{listRows: []dul.DUL{}, listTotal: 0} } bsvc := &baseServiceMock{ listRows: []base.Base{ {ID: baseA, BaseName: "Base A", DUL: true, BaseCategory: base.BaseCategory{Name: "hems"}}, {ID: baseB, BaseName: "Base B", DUL: true, BaseCategory: base.BaseCategory{Name: "regular"}}, {ID: baseC, BaseName: "Base C", DUL: false, BaseCategory: base.BaseCategory{Name: "regular"}}, }, } t.Run("admin sees all dul=true bases", func(t *testing.T) { svc := newSvc() h := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: true}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusOK { t.Fatalf("admin list status=%d", resp.StatusCode) } if len(svc.lastListID) != 2 { t.Fatalf("expected admin scope size=2 dul bases, got %d", len(svc.lastListID)) } }) t.Run("pilot sees only assigned dul bases", func(t *testing.T) { svc := newSvc() raw := baseAHex + "|" + baseANameHex + "|dul|" + stringsLower(hex.EncodeToString([]byte("hems"))) + "," + baseBHex + "|" + baseBNameHex + "|pilot|" + stringsLower(hex.EncodeToString([]byte("regular"))) item := &contact.ContactListItem{RoleCode: "pilot", BaseRolesRaw: &raw} h := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: false, item: item}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusOK { t.Fatalf("pilot list status=%d", resp.StatusCode) } if len(svc.lastListID) != 1 { t.Fatalf("expected pilot scope size=1 assigned dul base, got %d", len(svc.lastListID)) } }) t.Run("pilot with no assigned dul base gets empty list scope", func(t *testing.T) { svc := newSvc() raw := baseBHex + "|" + baseBNameHex + "|pilot|" + stringsLower(hex.EncodeToString([]byte("regular"))) item := &contact.ContactListItem{RoleCode: "pilot", BaseRolesRaw: &raw} h := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: false, item: item}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusOK { t.Fatalf("pilot list status=%d", resp.StatusCode) } if len(svc.lastListID) != 0 { t.Fatalf("expected pilot with no dul assignment scope size=0, got %d", len(svc.lastListID)) } }) t.Run("non pilot falls back to all dul=true bases", func(t *testing.T) { svc := newSvc() item := &contact.ContactListItem{RoleCode: "staff"} h := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{isAdmin: false, item: item}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusOK { t.Fatalf("staff list status=%d", resp.StatusCode) } if len(svc.lastListID) != 2 { t.Fatalf("expected staff scope size=2 dul bases, got %d", len(svc.lastListID)) } }) t.Run("without contact service falls back to all dul=true bases", func(t *testing.T) { svc := newSvc() h := NewDULHandler(svc).WithBaseService(bsvc) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusOK { t.Fatalf("list status=%d", resp.StatusCode) } if len(svc.lastListID) != 2 { t.Fatalf("expected scope size=2 dul bases without contact service, got %d", len(svc.lastListID)) } }) t.Run("base service error returns 400", func(t *testing.T) { svc := newSvc() h := NewDULHandler(svc).WithBaseService(&baseServiceMock{listErr: errors.New("base boom")}).WithContactService(&contactServiceMock{isAdmin: true}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 when base scope resolution fails, got %d", resp.StatusCode) } }) t.Run("contact admin check error returns 400", func(t *testing.T) { svc := newSvc() h := NewDULHandler(svc).WithBaseService(bsvc).WithContactService(&contactServiceMock{adminErr: errors.New("admin check boom")}) resp, _ := makeApp(h).Test(httpTestReq(http.MethodGet, "/list")) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 when admin check fails, got %d", resp.StatusCode) } }) } func TestDULHandler_ValidationAndHelpers(t *testing.T) { svc := &dulServiceMock{createErr: errors.New("boom"), getResult: nil} h := NewDULHandler(svc) app := newDULApp(h) resp, body := doReqDUL(t, app, http.MethodPost, "/api/v1/dul/create", []byte(`{`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 invalid json, got %d", resp.StatusCode) } errs, _ := body["errors"].([]any) if len(errs) == 0 { t.Fatalf("expected error payload") } if _, err := parseDULDate("2026-05-25"); err != nil { t.Fatalf("expected valid date") } if _, err := parseDULDate("25-05-2026"); err == nil { t.Fatalf("expected invalid date") } code, _ := inferDULErrorCode(fiber.StatusBadRequest, "Invalid JSON", "bad") if code != "DUL_INVALID_JSON" { t.Fatalf("unexpected code: %s", code) } code, _ = inferDULErrorCode(fiber.StatusUnprocessableEntity, "Validation error", "date must use format YYYY-MM-DD") if code != "DUL_DATE_INVALID" { t.Fatalf("unexpected code for date: %s", code) } } func stringsLower(v string) string { return strings.ToLower(v) } func TestDULHelperFunctions(t *testing.T) { t.Run("constructor chains", func(t *testing.T) { h := NewDULHandler(&dulServiceMock{}) h = h.WithBaseService(&baseServiceMock{}) h = h.WithContactService(&contactServiceMock{}) h = h.WithFileManagerService(nil) h = h.WithFileStorage(nil) if h == nil { t.Fatalf("expected handler not nil") } }) t.Run("toResource and image mapping", func(t *testing.T) { now := time.Now().UTC() row := &dul.DUL{ ID: uuidv7.MustBytes(), BaseID: uuidv7.MustBytes(), Name: "DUL A", Date: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), CreatedAt: now, UpdatedAt: now, No: 3, Base: &base.Base{BaseName: "Halim", BaseCategory: base.BaseCategory{Name: "hems"}}, Images: []*filemanager.Attachment{ {ID: uuidv7.MustBytes()}, nil, }, } res := NewDULHandler(&dulServiceMock{}).toResource(row) if res.Type != "dul" || res.Attributes.Base.Name != "Halim" || len(res.Attributes.Images) != 1 { t.Fatalf("unexpected mapped resource") } }) t.Run("inferDULErrorCode branches", func(t *testing.T) { cases := []struct { status int title string detail string }{ {fiber.StatusNotFound, "", ""}, {fiber.StatusUnprocessableEntity, "Validation error", "date invalid"}, {fiber.StatusUnprocessableEntity, "Validation error", "invalid uuid"}, {fiber.StatusUnprocessableEntity, "Validation error", "something"}, {fiber.StatusBadRequest, "Invalid JSON", ""}, {fiber.StatusBadRequest, "Create failed", ""}, {fiber.StatusBadRequest, "Update failed", ""}, {fiber.StatusBadRequest, "Delete failed", ""}, {fiber.StatusBadRequest, "List failed", ""}, {fiber.StatusInternalServerError, "", ""}, } for _, tc := range cases { code, errCode := inferDULErrorCode(tc.status, tc.title, tc.detail) if strings.TrimSpace(code) == "" || strings.TrimSpace(errCode) == "" { t.Fatalf("expected code for status=%d title=%q", tc.status, tc.title) } } }) t.Run("isPilotRoleContact and parseDULBaseIDsFromRaw", func(t *testing.T) { if isPilotRoleContact(nil) { t.Fatalf("nil item should be false") } item := &contact.ContactListItem{RoleCode: "pilot"} if !isPilotRoleContact(item) { t.Fatalf("role_code pilot should be true") } rawRoles := "x|y|doctor," + mustRoleRawContact(t, uuidv7.MustBytes(), "Pilot", "pilot") item2 := &contact.ContactListItem{RolesRaw: &rawRoles} if !isPilotRoleContact(item2) { t.Fatalf("roles raw pilot should be true") } baseID := uuidv7.MustBytes() baseHex := stringsLower(hex.EncodeToString(baseID)) nameHex := stringsLower(hex.EncodeToString([]byte("Base A"))) raw := baseHex + "|" + nameHex + "|dul," + baseHex + "|" + nameHex + "|dul" ids, set := parseDULBaseIDsFromRaw(&raw) if len(ids) != 1 || len(set) != 1 { t.Fatalf("expected deduped dul base ids") } bad := "invalid" ids, set = parseDULBaseIDsFromRaw(&bad) if len(ids) != 0 || len(set) != 0 { t.Fatalf("invalid raw should produce empty ids") } }) t.Run("syncImages branches", func(t *testing.T) { dulID := uuidv7.MustBytes() existingID := uuidv7.MustBytes() newFileID := uuidv7.MustBytes() newAttachmentID := uuidv7.MustBytes() created := 0 mock := &fileManagerAttachmentHandlerServiceMock{ listAttachmentsFn: func(context.Context, filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { return []filemanager.Attachment{{ID: existingID}}, 1, nil }, deleteAttachmentFn: func(context.Context, []byte) error { return nil }, getAttachmentFn: func(_ context.Context, _ []byte) (*filemanager.Attachment, error) { return &filemanager.Attachment{ID: newAttachmentID, FileID: newFileID}, nil }, createAttachmentFn: func(context.Context, filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { created++ return &filemanager.Attachment{ID: uuidv7.MustBytes()}, nil }, } h := NewDULHandler(&dulServiceMock{}).WithFileManagerService(mock) app := fiber.New() app.Get("/ok", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) raw := mustUUIDStringContact(t, newAttachmentID) if err := h.syncImages(c, dulID, []string{raw}); err != nil { t.Fatalf("unexpected syncImages err: %v", err) } return nil }) resp, err := app.Test(httpTestReq(http.MethodGet, "/ok")) if err != nil || resp.StatusCode != http.StatusOK || created != 1 { t.Fatalf("syncImages success not executed") } app2 := fiber.New() app2.Get("/bad", func(c *fiber.Ctx) error { err := h.syncImages(c, dulID, []string{"bad-uuid"}) if err == nil { t.Fatalf("expected invalid uuid error") } return nil }) if _, err := app2.Test(httpTestReq(http.MethodGet, "/bad")); err != nil { t.Fatalf("unexpected app2 err: %v", err) } t.Run("resubmit existing attachment uuid on update", func(t *testing.T) { // FE pulls the DUL via GET (which returns image.uuid = attachment id), // then PATCHes back the same uuid in image_attachment_ids. The bug we // fixed deleted the attachment first, then could not resolve the now-gone // uuid back to a file id and returned "image_attachment_ids[0] not found". existingAttID := uuidv7.MustBytes() existingFileID := uuidv7.MustBytes() deletedIDs := make([][]byte, 0, 1) createCalls := make([]filemanager.CreateAttachmentParams, 0, 1) lookupOrder := make([]string, 0, 2) mock := &fileManagerAttachmentHandlerServiceMock{ listAttachmentsFn: func(context.Context, filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { lookupOrder = append(lookupOrder, "list") return []filemanager.Attachment{{ID: existingAttID, FileID: existingFileID}}, 1, nil }, deleteAttachmentFn: func(_ context.Context, id []byte) error { lookupOrder = append(lookupOrder, "delete") deletedIDs = append(deletedIDs, append([]byte(nil), id...)) return nil }, getAttachmentFn: func(_ context.Context, id []byte) (*filemanager.Attachment, error) { lookupOrder = append(lookupOrder, "getAtt") if bytes.Equal(id, existingAttID) { return &filemanager.Attachment{ID: existingAttID, FileID: existingFileID}, nil } return nil, nil }, getFileFn: func(_ context.Context, _ []byte) (*filemanager.File, error) { lookupOrder = append(lookupOrder, "getFile") return nil, nil }, createAttachmentFn: func(_ context.Context, p filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { lookupOrder = append(lookupOrder, "create") createCalls = append(createCalls, p) return &filemanager.Attachment{ID: uuidv7.MustBytes(), FileID: append([]byte(nil), p.FileID...)}, nil }, } h := NewDULHandler(&dulServiceMock{}).WithFileManagerService(mock) dulID := uuidv7.MustBytes() app := fiber.New() app.Get("/resubmit", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) raw := mustUUIDStringContact(t, existingAttID) if err := h.syncImages(c, dulID, []string{raw}); err != nil { t.Fatalf("syncImages must succeed when resubmitting existing attachment uuid: %v", err) } return nil }) resp, err := app.Test(httpTestReq(http.MethodGet, "/resubmit")) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("unexpected resubmit response: err=%v status=%d", err, resp.StatusCode) } if len(createCalls) != 1 { t.Fatalf("expected exactly one CreateAttachment call, got %d", len(createCalls)) } if !bytes.Equal(createCalls[0].FileID, existingFileID) { t.Fatalf("CreateAttachment must reuse the original file id") } if len(deletedIDs) != 1 || !bytes.Equal(deletedIDs[0], existingAttID) { t.Fatalf("expected existing attachment to be deleted exactly once, got %v", deletedIDs) } // The lookup must happen BEFORE the delete; otherwise GetAttachmentByID // would race with the delete and return nil. getIdx, delIdx := -1, -1 for i, step := range lookupOrder { if step == "getAtt" && getIdx == -1 { getIdx = i } if step == "delete" && delIdx == -1 { delIdx = i } } if getIdx == -1 || delIdx == -1 || getIdx > delIdx { t.Fatalf("expected GetAttachmentByID before DeleteAttachment, lookup order=%v", lookupOrder) } }) t.Run("dedupe resubmitted file ids", func(t *testing.T) { // Sending the same file id twice must not violate the // (ref_type, ref_id, file_id) unique constraint at the attachments table. fileID := uuidv7.MustBytes() createCount := 0 mock := &fileManagerAttachmentHandlerServiceMock{ listAttachmentsFn: func(context.Context, filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { return nil, 0, nil }, getAttachmentFn: func(_ context.Context, _ []byte) (*filemanager.Attachment, error) { return nil, nil }, getFileFn: func(_ context.Context, id []byte) (*filemanager.File, error) { return &filemanager.File{ID: append([]byte(nil), id...), Status: filemanager.FileStatusReady}, nil }, createAttachmentFn: func(_ context.Context, _ filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { createCount++ return &filemanager.Attachment{ID: uuidv7.MustBytes()}, nil }, } h := NewDULHandler(&dulServiceMock{}).WithFileManagerService(mock) dulID := uuidv7.MustBytes() app := fiber.New() app.Get("/dedupe", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) raw := mustUUIDStringContact(t, fileID) if err := h.syncImages(c, dulID, []string{raw, raw}); err != nil { t.Fatalf("unexpected err on duplicate ids: %v", err) } return nil }) if _, err := app.Test(httpTestReq(http.MethodGet, "/dedupe")); err != nil { t.Fatalf("unexpected dedupe err: %v", err) } if createCount != 1 { t.Fatalf("duplicate ids must collapse to one CreateAttachment call, got %d", createCount) } }) t.Run("accept file uuid fallback", func(t *testing.T) { createdFallback := 0 mockFallback := &fileManagerAttachmentHandlerServiceMock{ listAttachmentsFn: func(context.Context, filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { return nil, 0, nil }, getAttachmentFn: func(_ context.Context, _ []byte) (*filemanager.Attachment, error) { return nil, nil }, getFileFn: func(_ context.Context, id []byte) (*filemanager.File, error) { return &filemanager.File{ID: append([]byte(nil), id...), Status: filemanager.FileStatusReady}, nil }, createAttachmentFn: func(_ context.Context, p filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { createdFallback++ return &filemanager.Attachment{ID: uuidv7.MustBytes(), FileID: append([]byte(nil), p.FileID...)}, nil }, } hFallback := NewDULHandler(&dulServiceMock{}).WithFileManagerService(mockFallback) appFallback := fiber.New() appFallback.Get("/file-fallback", func(c *fiber.Ctx) error { c.Locals("user_id", uuidv7.MustBytes()) raw := mustUUIDStringContact(t, newFileID) if err := hFallback.syncImages(c, dulID, []string{raw}); err != nil { t.Fatalf("unexpected fallback err: %v", err) } return nil }) respFallback, err := appFallback.Test(httpTestReq(http.MethodGet, "/file-fallback")) if err != nil || respFallback.StatusCode != http.StatusOK || createdFallback != 1 { t.Fatalf("fallback path not executed") } }) }) } func TestDULEventsStream(t *testing.T) { hub := filemanagerrealtime.NewHub(1, nil) actor := uuidv7.MustBytes() userUUID, _ := uuidv7.BytesToString(actor) app := fiber.New() fctx := &fasthttp.RequestCtx{} fctx.Request.SetRequestURI("/api/v1/dul/events") c := app.AcquireCtx(fctx) c.Locals("user_id", actor) if err := NewFileManagerEventsHandler(hub).Stream(c); err != nil { t.Fatalf("unexpected stream setup error: %v", err) } hub.PublishToUsers([]string{userUUID}, filemanagerrealtime.FileStatusEvent{ FileUUID: mustUUIDStringContact(t, uuidv7.MustBytes()), Status: "ready", Name: "dul-image.png", MimeType: "image/png", UpdatedAt: time.Now().UTC().Format(time.RFC3339), }) go func() { time.Sleep(10 * time.Millisecond) closeFirstHubSubscriberForUser(hub, userUUID) }() bodyStr := c.Context().Response.String() app.ReleaseCtx(c) if !strings.Contains(bodyStr, ": connected") { t.Fatalf("expected connected comment in body: %q", bodyStr) } if !strings.Contains(bodyStr, "event: file.updated") { t.Fatalf("expected file.updated event in body: %q", bodyStr) } if !strings.Contains(bodyStr, "\"file_id\"") || !strings.Contains(bodyStr, "\"status\":\"ready\"") { t.Fatalf("expected file.updated payload in body: %q", bodyStr) } } func httpTestReq(method, path string) *http.Request { req, _ := http.NewRequest(method, path, nil) return req }