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

1000 lines
40 KiB
Go

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)
}
}