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

932 lines
33 KiB
Go

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
}