package handlers import ( "bytes" "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/valyala/fasthttp" "wucher/internal/domain/base" filemanager "wucher/internal/domain/file_manager" filemanagerrealtime "wucher/internal/realtime/filemanager" "wucher/internal/service" "wucher/internal/shared/pkg/apperrors" "wucher/internal/shared/pkg/attachmentresolver" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/transport/http/jsonapi" ) var ( testBaseShiftStart = "06:00:00" testBaseShiftEnd = "21:00:00" ) type memBaseRepo struct { rows map[string]map[string]*base.Base createBaseErr error updateBaseErr error deleteBaseErr error getBaseByIDErr error listBasesErr error listRows []base.Base listTotal int64 lastCreateBase *base.Base lastCreateCategory string lastUpdateBase *base.Base lastUpdateCategory string lastDeleteID []byte lastDeleteCategory string lastGetID []byte lastGetCategory string lastListFilter string lastListSort string lastListLimit int lastListOffset int lastListCategory string lastListDUL *bool } type basePresignStorageMock struct { url string err error } func (m *basePresignStorageMock) PresignGetObject(_ context.Context, _ string, _ time.Duration) (string, error) { if m.err != nil { return "", m.err } return m.url, nil } func newMemBaseRepo() *memBaseRepo { return &memBaseRepo{ rows: map[string]map[string]*base.Base{ base.CategoryKeyRegular: {}, base.CategoryKeyHEMS: {}, }, } } func (r *memBaseRepo) CreateBase(_ context.Context, row *base.Base, categoryType string) error { if r.createBaseErr != nil { return r.createBaseErr } if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } r.lastCreateBase = row r.lastCreateCategory = categoryType if _, ok := r.rows[categoryType]; !ok { r.rows[categoryType] = map[string]*base.Base{} } r.rows[categoryType][string(row.ID)] = row return nil } func (r *memBaseRepo) UpdateBase(_ context.Context, row *base.Base, categoryType string) error { if r.updateBaseErr != nil { return r.updateBaseErr } r.lastUpdateBase = row r.lastUpdateCategory = categoryType if _, ok := r.rows[categoryType]; !ok { r.rows[categoryType] = map[string]*base.Base{} } r.rows[categoryType][string(row.ID)] = row return nil } func (r *memBaseRepo) DeleteBase(_ context.Context, id []byte, categoryType string) error { if r.deleteBaseErr != nil { return r.deleteBaseErr } r.lastDeleteID = append([]byte(nil), id...) r.lastDeleteCategory = categoryType if m, ok := r.rows[categoryType]; ok { delete(m, string(id)) } return nil } func (r *memBaseRepo) GetBaseByID(_ context.Context, id []byte, categoryType string) (*base.Base, error) { if r.getBaseByIDErr != nil { return nil, r.getBaseByIDErr } r.lastGetID = append([]byte(nil), id...) r.lastGetCategory = categoryType if m, ok := r.rows[categoryType]; ok { return m[string(id)], nil } return nil, nil } func (r *memBaseRepo) ListBases(_ context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) { if r.listBasesErr != nil { return nil, 0, r.listBasesErr } r.lastListFilter = filter r.lastListSort = sort r.lastListLimit = limit r.lastListOffset = offset r.lastListCategory = categoryType r.lastListDUL = dul if r.listRows != nil { return r.listRows, r.listTotal, nil } out := make([]base.Base, 0) for _, row := range r.rows[categoryType] { out = append(out, *row) } return out, int64(len(out)), nil } func newBaseTestApp(h *BaseHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/bases/create", h.CreateBase) app.Patch("/api/v1/bases/update/:uuid", h.UpdateBase) app.Delete("/api/v1/bases/delete/:uuid", h.DeleteBase) app.Get("/api/v1/bases/get/:uuid", h.GetBase) app.Get("/api/v1/bases/get-all", h.ListBases) app.Get("/api/v1/bases/get-all/dt", h.ListBasesDatatable) return app } func newBaseEventsTestApp(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/bases/events", h.Stream) return app } func doJSONRequestBase(t *testing.T, app *fiber.App, method, path string, body []byte) (*http.Response, []byte) { 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) } respBody, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp, respBody } func mustUUIDStringBase(t *testing.T, id []byte) string { t.Helper() s, err := uuidv7.BytesToString(id) if err != nil { t.Fatalf("uuid to string: %v", err) } return s } func TestNewBaseHandler(t *testing.T) { h := NewBaseHandler(service.NewBaseService(newMemBaseRepo())) if h == nil || h.validate == nil { t.Fatalf("expected handler and validator initialized") } h = h.WithFileManagerService(nil) h = h.WithFileResolver(nil, nil) if h == nil { t.Fatalf("expected chain methods return handler") } } func TestBaseHandlerCreateBase(t *testing.T) { t.Run("invalid category", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create?category_type=oops", []byte(`{"data":{"type":"base","attributes":{"base":"A","latitude":47.3769,"longitude":8.5417}}}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("missing coordinates", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"No coords"}}}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid coordinates", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Bad coords","latitude":91,"longitude":181}}}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("regular success", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Regular A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","sortkey":3,"notes":"Primary note","is_active":false}}}`)) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if repo.lastCreateCategory != base.CategoryKeyRegular { t.Fatalf("expected regular category, got %q", repo.lastCreateCategory) } if repo.lastCreateBase == nil || repo.lastCreateBase.SortKey == nil || *repo.lastCreateBase.SortKey != 3 { t.Fatalf("expected sortkey mapped on create") } if repo.lastCreateBase.Notes != "Primary note" || repo.lastCreateBase.IsActive { t.Fatalf("expected notes/is_active mapped on create") } if !bytes.Contains(body, []byte(`"type":"base"`)) { t.Fatalf("expected base resource type, body=%s", string(body)) } }) t.Run("operational shift times success", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"09:00","end":"18:00"}},{"date_start":"2026-04-13","date_end":"2026-04-13","shift_time":{"start":"09:00","end":"17:00"}}]}}}`)) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if repo.lastCreateBase == nil || len(repo.lastCreateBase.OperationalShiftTimes) != 2 { t.Fatalf("expected operational shift times mapped on create") } if got := repo.lastCreateBase.OperationalShiftTimes[1].DateStart.Format("2006-01-02"); got != "2026-04-13" { t.Fatalf("unexpected operational shift date start: %s", got) } if got := repo.lastCreateBase.OperationalShiftTimes[1].DateEnd.Format("2006-01-02"); got != "2026-04-13" { t.Fatalf("unexpected operational shift date end: %s", got) } if repo.lastCreateBase.OperationalShiftTimes[0].ShiftStart != "09:00:00" || repo.lastCreateBase.OperationalShiftTimes[1].ShiftEnd != "17:00:00" { t.Fatalf("expected both operational shift times mapped on create, got %#v", repo.lastCreateBase.OperationalShiftTimes) } if !bytes.Contains(body, []byte(`"date_start":"2026-04-12"`)) || !bytes.Contains(body, []byte(`"date_end":"2026-04-12"`)) || !bytes.Contains(body, []byte(`"date_start":"2026-04-13"`)) || !bytes.Contains(body, []byte(`"date_end":"2026-04-13"`)) { t.Fatalf("expected operational shift dates in response, body=%s", string(body)) } if !bytes.Contains(body, []byte(`"operational_shift_times":[`)) { t.Fatalf("expected operational_shift_times in response, body=%s", string(body)) } }) t.Run("operational shift times fixed response includes values", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with fixed ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-06-03","date_end":"2026-06-04","start_time_type":"FIXED","end_time_type":"FIXED","shift_time":{"start":"03:00","end":"05:00"}}]}}}`)) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`"shift_time":{"start":"03:00","end":"05:00"}`)) { t.Fatalf("expected fixed operational shift_time values in response, body=%s", string(body)) } }) t.Run("operational shift times invalid order", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with bad ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"18:00","end":"09:00"}}]}}}`)) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`shift_time.start must be earlier than shift_time.end`)) { t.Fatalf("expected shift order validation error, body=%s", string(body)) } if repo.lastCreateBase != nil { t.Fatalf("expected create not to reach repo on invalid shift order") } }) t.Run("hems success", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create?category_type=hems", []byte(`{"data":{"type":"hems_base","attributes":{"base":"HEMS A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`)) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if repo.lastCreateCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category, got %q", repo.lastCreateCategory) } if !bytes.Contains(body, []byte(`"type":"hems_base"`)) { t.Fatalf("expected hems_base resource type, body=%s", string(body)) } }) t.Run("hems success from body base_category without query", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"HEMS from body","base_category":"hems","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`)) if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } if repo.lastCreateCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category, got %q", repo.lastCreateCategory) } if !bytes.Contains(body, []byte(`"base_category":"hems"`)) { t.Fatalf("expected base_category hems, body=%s", string(body)) } }) } func TestBaseHandlerGetBase(t *testing.T) { repo := newMemBaseRepo() hemsID := uuidv7.MustBytes() fotoID := uuidv7.MustBytes() repo.rows[base.CategoryKeyHEMS][string(hemsID)] = &base.Base{ID: hemsID, BaseName: "HEMS 1", FotoAttachmentID: fotoID, CreatedAt: time.Now(), UpdatedAt: time.Now()} h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, hemsID)+"?category_type=hems", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastGetCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category forwarded") } if !bytes.Contains(body, []byte(`"type":"hems_base"`)) { t.Fatalf("expected hems_base type") } if !bytes.Contains(body, []byte(`"base_category":"hems"`)) { t.Fatalf("expected base_category hems") } fotoIDStr, _ := uuidv7.BytesToString(fotoID) bodyStr := string(body) if !strings.Contains(bodyStr, `"uuid":"`+fotoIDStr+`"`) { t.Fatalf("expected foto uuid when storage unavailable, body=%s", bodyStr) } if !strings.Contains(bodyStr, `"download_url":""`) { t.Fatalf("expected empty download_url when storage unavailable, body=%s", bodyStr) } } func TestBaseHelperFunctions(t *testing.T) { t.Run("cloneIntPointerBase and preferStringPtr", func(t *testing.T) { v := 7 cp := cloneIntPointerBase(&v) if cp == nil || *cp != 7 || cp == &v { t.Fatalf("cloneIntPointerBase invalid clone") } if cloneIntPointerBase(nil) != nil { t.Fatalf("expected nil clone for nil input") } a, b := "a", "b" if got := preferStringPtr(&a, &b); got == nil || *got != "a" { t.Fatalf("preferStringPtr should prioritize primary") } if got := preferStringPtr(nil, &b); got == nil || *got != "b" { t.Fatalf("preferStringPtr should use fallback") } }) t.Run("parseUUIDList and uuidListToStrings", func(t *testing.T) { id1 := mustUUIDStringBase(t, uuidv7.MustBytes()) id2 := mustUUIDStringBase(t, uuidv7.MustBytes()) got, errObj := parseUUIDList([]string{" ", id1, id1, id2}, "/x") if errObj != nil || len(got) != 2 { t.Fatalf("expected deduped parsed ids, got len=%d err=%v", len(got), errObj) } if _, errObj = parseUUIDList([]string{"not-uuid"}, "/x"); errObj == nil { t.Fatalf("expected validation error for invalid uuid") } strs := uuidListToStrings(got) if len(strs) != 2 { t.Fatalf("uuidListToStrings expected 2 got %d", len(strs)) } if uuidListToStrings(nil) != nil { t.Fatalf("uuidListToStrings nil input should be nil") } }) t.Run("baseContactResources", func(t *testing.T) { id := uuidv7.MustBytes() rows := []base.BaseContactPerson{ {ContactID: id, FirstName: "A", LastName: "B"}, {ContactID: []byte{1, 2}, FirstName: "X"}, } out := baseContactResources(rows) if len(out) != 1 || out[0].FirstName != "A" { t.Fatalf("expected single valid contact resource") } if baseContactResources(nil) != nil { t.Fatalf("nil input should return nil") } }) t.Run("shift parsing helpers", func(t *testing.T) { startRaw := "07:30" endRaw := "16:45:30" start, end, errObj := parseBaseShiftRange(&startRaw, &endRaw, nil, "/s", "/e", "/l") if errObj != nil || start == nil || end == nil { t.Fatalf("expected valid shift parse") } legacy := "09:00-18:00" start, end, errObj = parseBaseShiftRange(nil, nil, &legacy, "/s", "/e", "/l") if errObj != nil || start == nil || end == nil || start.Format("15:04") != "09:00" { t.Fatalf("expected legacy shift parse") } bad := "invalid" _, _, errObj = parseBaseShiftRange(nil, nil, &bad, "/s", "/e", "/l") if errObj == nil { t.Fatalf("expected error for invalid legacy shift") } if tokens := extractBaseShiftClockTokens("x 01:02:03 y 14:15"); len(tokens) != 2 { t.Fatalf("expected two extracted tokens") } a, b := extractBaseShiftRangeTokens("10:10") if a != "10:10" || b != "10:10" { t.Fatalf("single token should duplicate") } }) t.Run("query and category helpers", func(t *testing.T) { app := fiber.New() ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) defer app.ReleaseCtx(ctx) ctx.Request().URI().SetQueryString("dul=true&category_type=hems") dul, errObj := parseOptionalBoolQueryParam(ctx, "dul") if errObj != nil || dul == nil || !*dul { t.Fatalf("expected dul=true parsed") } ctx.Request().URI().SetQueryString("dul=oops") if _, errObj = parseOptionalBoolQueryParam(ctx, "dul"); errObj == nil { t.Fatalf("expected bool validation error") } ctx.Request().URI().SetQueryString("category=hems") if got := baseCategoryQueryValue(ctx); got != "hems" { t.Fatalf("expected category fallback value") } if categoryNotFoundDetail(base.CategoryKeyHEMS) != "hems base not found" { t.Fatalf("unexpected category detail for hems") } }) t.Run("uuid parser and converter helpers", func(t *testing.T) { idStr := mustUUIDStringBase(t, uuidv7.MustBytes()) if got, errObj := parseUUIDOrClear(idStr, "/p"); errObj != nil || len(got) != 16 { t.Fatalf("parseUUIDOrClear should parse valid uuid") } if got, errObj := parseUUIDOrClear(" ", "/p"); errObj != nil || got != nil { t.Fatalf("parseUUIDOrClear should clear blank") } if _, errObj := parseUUIDOrClear("bad", "/p"); errObj == nil { t.Fatalf("expected parseUUIDOrClear error") } if bytesUUIDToString(nil) != "" { t.Fatalf("empty bytes should return empty") } if bytesUUIDToString([]byte{1, 2}) != "" { t.Fatalf("invalid bytes should return empty") } }) t.Run("inferBaseErrorCode branches", func(t *testing.T) { cases := []struct { status int title string detail string }{ {fiber.StatusNotFound, "", ""}, {fiber.StatusConflict, "", ""}, {fiber.StatusUnprocessableEntity, "Validation error", "invalid uuid"}, {fiber.StatusUnprocessableEntity, "Validation error", "category_type/base_category must be one of"}, {fiber.StatusUnprocessableEntity, "Validation error", "time must be HH:MM"}, {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 := inferBaseErrorCode(tc.status, tc.title, tc.detail) if strings.TrimSpace(code) == "" || strings.TrimSpace(errCode) == "" { t.Fatalf("expected non-empty codes for status=%d title=%q", tc.status, tc.title) } } }) t.Run("writeBaseErrors populates status and code", func(t *testing.T) { app := fiber.New() app.Get("/", func(c *fiber.Ctx) error { return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Title: "Invalid JSON", Detail: "oops"}}) }) req := httptest.NewRequest(http.MethodGet, "/", nil) resp, err := app.Test(req) if err != nil || resp.StatusCode != http.StatusBadRequest { t.Fatalf("writeBaseErrors failed err=%v status=%d", err, resp.StatusCode) } }) t.Run("base attachment resolver branches", func(t *testing.T) { handler := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{}) baseID := uuidv7.MustBytes() app := fiber.New() app.Get("/invalid-attachment", func(c *fiber.Ctx) error { raw := "bad" _, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{ RawAttachmentID: &raw, AttachmentField: "attachment_id", FileField: "file_uuid", }, "Update failed") if !handled { t.Fatalf("expected handled for invalid attachment") } return nil }) app.Get("/invalid-file", func(c *fiber.Ctx) error { raw := "bad" _, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{ RawFileID: &raw, AttachmentField: "attachment_id", FileField: "file_uuid", FileManager: nil, }, "Update failed") if !handled { t.Fatalf("expected handled for invalid file path") } return nil }) app.Get("/require", func(c *fiber.Ctx) error { _, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{ RequireValue: true, AttachmentField: "attachment_id", FileField: "file_uuid", }, "Update failed") if !handled { t.Fatalf("expected handled for require value") } return nil }) app.Get("/not-found", func(c *fiber.Ctx) error { id := mustUUIDStringBase(t, uuidv7.MustBytes()) _, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{ RawAttachmentID: &id, AttachmentField: "attachment_id", FileField: "file_uuid", ValidateAttachmentExists: true, AttachmentExists: func(context.Context, []byte) (bool, error) { return false, nil }, }, "Update failed") if !handled { t.Fatalf("expected handled for attachment not found") } return nil }) app.Get("/existing-or-file", func(c *fiber.Ctx) error { raw := mustUUIDStringBase(t, uuidv7.MustBytes()) mock := &fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) { return &filemanager.Attachment{ID: uuidv7.MustBytes()}, nil }, } h2 := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(mock) id, handled := h2.resolveExistingBaseFotoAttachmentOrFileID(c, baseID, &raw) if handled || len(id) != 16 { t.Fatalf("expected existing attachment id") } return nil }) app.Get("/existing-error", func(c *fiber.Ctx) error { raw := mustUUIDStringBase(t, uuidv7.MustBytes()) mock := &fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) { return nil, errors.New("lookup failed") }, } h2 := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(mock) _, handled := h2.resolveExistingBaseFotoAttachmentOrFileID(c, baseID, &raw) if !handled { t.Fatalf("expected handled error for existing attachment lookup") } return nil }) app.Get("/resolve-base-foto", func(c *fiber.Ctx) error { _, handled := handler.resolveBaseFotoAttachmentID(c, baseID, nil) if handled { t.Fatalf("nil raw should not be handled as error") } return nil }) for _, path := range []string{"/invalid-attachment", "/invalid-file", "/require", "/not-found", "/existing-or-file", "/existing-error", "/resolve-base-foto"} { req := httptest.NewRequest(http.MethodGet, path, nil) resp, err := app.Test(req) if err != nil { t.Fatalf("request %s err=%v", path, err) } if path != "/existing-or-file" && path != "/resolve-base-foto" && resp.StatusCode == http.StatusOK { t.Fatalf("expected error status for %s", path) } } }) } func TestBaseHandlerGetBase_UsesDirectPresignURLWhenStorageAvailable(t *testing.T) { repo := newMemBaseRepo() hemsID := uuidv7.MustBytes() fotoID := uuidv7.MustBytes() repo.rows[base.CategoryKeyHEMS][string(hemsID)] = &base.Base{ ID: hemsID, BaseName: "HEMS 1", FotoAttachmentID: fotoID, FotoAttachment: &filemanager.Attachment{ ID: fotoID, FileID: uuidv7.MustBytes(), File: &filemanager.File{ID: uuidv7.MustBytes(), ObjectKey: "base/hems-1.jpg"}, }, CreatedAt: time.Now(), UpdatedAt: time.Now(), } h := NewBaseHandler(service.NewBaseService(repo)).WithFileStorage( &basePresignStorageMock{url: "https://signed.example/base/hems-1.jpg?sig=abc"}, ) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, hemsID)+"?category_type=hems", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`"download_url":"https://signed.example/base/hems-1.jpg?sig=abc"`)) { t.Fatalf("expected direct presigned URL, body=%s", string(body)) } } func TestBaseHandlerUpdateDelete(t *testing.T) { repo := newMemBaseRepo() id := uuidv7.MustBytes() repo.rows[base.CategoryKeyHEMS][string(id)] = &base.Base{ID: id, BaseName: "Old", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now()} h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) idStr := mustUUIDStringBase(t, id) resp, _ := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"New","latitude":47.3769,"longitude":8.5417}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastUpdateCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category update") } fotoID := uuidv7.MustBytes() repo.rows[base.CategoryKeyHEMS][string(id)].FotoAttachmentID = append([]byte(nil), fotoID...) resp, _ = doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"Still New","latitude":47.3769,"longitude":8.5417,"file_uuid":""}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.rows[base.CategoryKeyHEMS][string(id)].FotoAttachmentID != nil { t.Fatalf("expected foto attachment to be cleared") } resp, updateBody := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"With Ops","latitude":47.3769,"longitude":8.5417,"operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"09:00","end":"18:00"}},{"date_start":"2026-04-13","date_end":"2026-04-13","shift_time":{"start":"09:00","end":"17:00"}}]}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastUpdateBase == nil || len(repo.lastUpdateBase.OperationalShiftTimes) != 2 { t.Fatalf("expected operational shift times mapped on update") } if repo.lastUpdateBase.OperationalShiftTimes[0].ShiftStart != "09:00:00" || repo.lastUpdateBase.OperationalShiftTimes[1].ShiftEnd != "17:00:00" { t.Fatalf("expected operational shift mapping on update, got %#v", repo.lastUpdateBase.OperationalShiftTimes) } if !bytes.Contains(updateBody, []byte(`"date_start":"2026-04-12"`)) || !bytes.Contains(updateBody, []byte(`"date_end":"2026-04-12"`)) || !bytes.Contains(updateBody, []byte(`"date_start":"2026-04-13"`)) || !bytes.Contains(updateBody, []byte(`"date_end":"2026-04-13"`)) { t.Fatalf("expected operational shift dates in update response, body=%s", string(updateBody)) } resp, fixedBody := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"With Ops Fixed","latitude":47.3769,"longitude":8.5417,"operational_shift_times":[{"date_start":"2026-06-03","date_end":"2026-06-04","start_time_type":"FIXED","end_time_type":"FIXED","shift_time":{"start":"03:00","end":"05:00"}}]}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !bytes.Contains(fixedBody, []byte(`"shift_time":{"start":"03:00","end":"05:00"}`)) { t.Fatalf("expected fixed operational shift_time values in update response, body=%s", string(fixedBody)) } resp, _ = doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"latitude":47.3769,"longitude":8.5417,"operational_shift_times":[]}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastUpdateBase == nil || repo.lastUpdateBase.OperationalShiftTimes == nil || len(repo.lastUpdateBase.OperationalShiftTimes) != 0 { t.Fatalf("expected explicit empty operational_shift_times to clear rows") } resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+idStr+"?category_type=hems", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastDeleteCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category delete") } if !strings.Contains(string(body), "hems_base_delete") { t.Fatalf("expected hems delete resource type") } resp, body = doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, uuidv7.MustBytes())+"?category_type=hems", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 for missing base delete, got %d body=%s", resp.StatusCode, string(body)) } } func TestBaseHandlerUpdateShiftTimeOnly(t *testing.T) { repo := newMemBaseRepo() id := uuidv7.MustBytes() repo.rows[base.CategoryKeyRegular][string(id)] = &base.Base{ ID: id, BaseName: "Old", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now(), } h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) idStr := mustUUIDStringBase(t, id) resp, body := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr, []byte(`{"data":{"type":"base","id":"`+idStr+`","attributes":{"shift_time":{"start":"07:00","end":"20:00"}}}}`)) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastUpdateBase == nil || repo.lastUpdateBase.DefaultShiftStart == "" || repo.lastUpdateBase.DefaultShiftEnd == "" { t.Fatalf("expected shift_time applied to repo update row") } if got := repo.lastUpdateBase.DefaultShiftStart; got != "07:00:00" { t.Fatalf("expected updated start 07:00:00, got %s", got) } if got := repo.lastUpdateBase.DefaultShiftEnd; got != "20:00:00" { t.Fatalf("expected updated end 20:00:00, got %s", got) } if !bytes.Contains(body, []byte(`"shift_time":{"start":"07:00","end":"20:00"}`)) { t.Fatalf("expected response shift_time to update, body=%s", string(body)) } } func TestBaseHandlerDelete_NotFoundAndConflict(t *testing.T) { t.Run("not found returns 404", func(t *testing.T) { repo := newMemBaseRepo() h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, uuidv7.MustBytes())+"?category_type=hems", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`"BASE_NOT_FOUND"`)) { t.Fatalf("expected base not found code, body=%s", string(body)) } }) t.Run("relation conflict returns 409", func(t *testing.T) { repo := newMemBaseRepo() id := uuidv7.MustBytes() repo.rows[base.CategoryKeyHEMS][string(id)] = &base.Base{ID: id, BaseName: "Has refs", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now()} repo.deleteBaseErr = apperrors.NewDeleteConflictError("takeover") h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, id)+"?category_type=hems", nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("expected 409, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`"code":"BASE_DELETE_CONFLICT"`)) { t.Fatalf("expected specific base delete conflict code, body=%s", string(body)) } if !bytes.Contains(body, []byte(`"error_code":"4091615"`)) { t.Fatalf("expected specific base delete conflict error_code, body=%s", string(body)) } if !bytes.Contains(body, []byte(`takeover`)) { t.Fatalf("expected relation detail to mention takeover, body=%s", string(body)) } }) } func TestBaseHandlerListBases(t *testing.T) { repo := newMemBaseRepo() repo.listRows = []base.Base{{ ID: uuidv7.MustBytes(), BaseName: "Lude", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now(), }} repo.listTotal = 1 h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?category_type=hems&search=abc&page=0&limit=101&sort=-base", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastListCategory != base.CategoryKeyHEMS || repo.lastListFilter != "abc" || repo.lastListSort != "base DESC" || repo.lastListLimit != 0 || repo.lastListOffset != 0 { t.Fatalf("unexpected forwarded list args") } resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?category_type=hems&sort=-sortkey", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, base ASC" { t.Fatalf("unexpected sortkey sort forwarding: %q", repo.lastListSort) } if !bytes.Contains(body, []byte(`"type":"hems_base"`)) { t.Fatalf("expected hems_base list resource type") } if !bytes.Contains(body, []byte(`"base_category":"hems"`)) { t.Fatalf("expected base_category hems") } if !bytes.Contains(body, []byte(`"shift_time":{"start":"06:00","end":"21:00"}`)) { t.Fatalf("expected shift_time start/end in HH:MM format, body=%s", string(body)) } if bytes.Contains(body, []byte(`"default_shift_start"`)) || bytes.Contains(body, []byte(`"default_shift_end"`)) || bytes.Contains(body, []byte(`"default_shift_time"`)) { t.Fatalf("expected old default_shift_* fields removed, body=%s", string(body)) } resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?base_category=regular", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 for base_category alias, got %d", resp.StatusCode) } if repo.lastListCategory != base.CategoryKeyRegular { t.Fatalf("expected regular category from base_category alias, got %q", repo.lastListCategory) } resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?base_category=hems", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 for base_category hems alias, got %d", resp.StatusCode) } if repo.lastListCategory != base.CategoryKeyHEMS { t.Fatalf("expected hems category from base_category alias, got %q", repo.lastListCategory) } } func TestBaseHandlerBaseResource_ShiftLabelsForNonFixedTypes(t *testing.T) { repo := newMemBaseRepo() id := uuidv7.MustBytes() repo.rows[base.CategoryKeyRegular][string(id)] = &base.Base{ ID: id, BaseName: "Twilight Base", DefaultStartTimeType: base.ShiftTimeTypeBMCT, DefaultEndTimeType: base.ShiftTimeTypeECET, OperationalShiftTimes: []base.BaseOperationalShiftTime{ { StartTimeType: base.ShiftTimeTypeBMCT, EndTimeType: base.ShiftTimeTypeECET, }, }, CreatedAt: time.Now(), UpdatedAt: time.Now(), } h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, id)+"?category_type=regular", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if !bytes.Contains(body, []byte(`"shift_time":{"start":"BMCT","end":"ECET"}`)) { t.Fatalf("expected BMCT/ECET shift labels in response, body=%s", string(body)) } if !bytes.Contains(body, []byte(`"operational_shift_times":[{"date_start":"","date_end":"","start_time_type":"BMCT","end_time_type":"ECET","shift_time":{"start":"BMCT","end":"ECET"}}]`)) { t.Fatalf("expected operational shift labels in response, body=%s", string(body)) } } func TestBaseHandlerListBasesDatatable(t *testing.T) { repo := newMemBaseRepo() repo.listRows = []base.Base{{ID: uuidv7.MustBytes(), BaseName: "Reg", CreatedAt: time.Now(), UpdatedAt: time.Now()}} repo.listTotal = 1 h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, _ := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all/dt?page=0&limit=0&size=101&draw=7&search=abc", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.lastListCategory != "" || repo.lastListSort != "" || repo.lastListLimit != 100 || repo.lastListOffset != 0 { t.Fatalf("unexpected datatable args") } } func TestBaseHandlerListAndCreateError(t *testing.T) { repo := newMemBaseRepo() repo.listBasesErr = errors.New("list failed") repo.createBaseErr = errors.New("create failed") h := NewBaseHandler(service.NewBaseService(repo)) app := newBaseTestApp(h) resp, _ := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all", nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 list, got %d", resp.StatusCode) } resp, _ = doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`)) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 create, got %d", resp.StatusCode) } } func TestBaseEventsStream(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/bases/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: mustUUIDStringBase(t, uuidv7.MustBytes()), Status: "ready", Name: "base.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) } }