package handlers import ( "bytes" "context" "errors" "io" "net/http" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/valyala/fasthttp" filemanager "wucher/internal/domain/file_manager" "wucher/internal/domain/user" filemanagerrealtime "wucher/internal/realtime/filemanager" "wucher/internal/service" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" ) type memUserRepo struct { users map[string]*user.User createErr error updateErr error deleteErr error getErr error listErr error listUsers []user.User listTotal int64 } func newMemUserRepo() *memUserRepo { return &memUserRepo{users: map[string]*user.User{}} } func (r *memUserRepo) Create(_ context.Context, u *user.User) error { if r.createErr != nil { return r.createErr } if len(u.ID) == 0 { u.ID = uuidv7.MustBytes() } r.users[string(u.ID)] = u return nil } func (r *memUserRepo) Update(_ context.Context, u *user.User) error { if r.updateErr != nil { return r.updateErr } r.users[string(u.ID)] = u return nil } func (r *memUserRepo) Delete(_ context.Context, id []byte) error { if r.deleteErr != nil { return r.deleteErr } delete(r.users, string(id)) return nil } func (r *memUserRepo) GetByID(_ context.Context, id []byte) (*user.User, error) { if r.getErr != nil { return nil, r.getErr } return r.users[string(id)], nil } func (r *memUserRepo) GetByEmail(_ context.Context, _ string) (*user.User, error) { return nil, nil } func (r *memUserRepo) GetByUsername(_ context.Context, _ string) (*user.User, error) { return nil, nil } func (r *memUserRepo) List(_ context.Context, _ string, _ string, _ int, _ int) ([]user.User, int64, error) { if r.listErr != nil { return nil, 0, r.listErr } return r.listUsers, r.listTotal, nil } func (r *memUserRepo) GetLastNameByID(_ context.Context, _ []byte) (string, error) { return "", nil } func TestNewUserHandler(t *testing.T) { h := NewUserHandler(service.NewUserService(newMemUserRepo())) if h == nil || h.validate == nil { t.Fatalf("expected handler and validator initialized") } } func TestUserHandler_Create(t *testing.T) { t.Run("success", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() actor := uuidv7.MustBytes() app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", actor) return c.Next() }) app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"` + roleID + `"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !bytes.Contains(body, []byte(`"username":"john.doe"`)) { t.Fatalf("expected response to include username, got %s", string(body)) } if len(repo.users) != 1 { t.Fatalf("expected 1 created user, got %d", len(repo.users)) } for _, created := range repo.users { if created.Username != "john.doe" { t.Fatalf("expected username persisted, got %q", created.Username) } if created.Timezone != "UTC" { t.Fatalf("expected default timezone UTC, got %q", created.Timezone) } if string(created.CreatedBy) != string(actor) { t.Fatalf("expected created_by from actor") } } }) t.Run("success with role_ids", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) extraRoleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"roles@e.com","username":"roles.user","first_name":"Roles","last_name":"User","role_id":"` + roleID + `","role_ids":["` + roleID + `","` + extraRoleID + `"]}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } for _, created := range repo.users { if len(created.RoleIDs) != 2 { t.Fatalf("expected 2 role ids persisted, got %d", len(created.RoleIDs)) } } }) t.Run("invalid json", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("invalid role id", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"bad"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid image_uuid requires file manager and returns bad request", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"` + roleID + `","image_uuid":"bad"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("image_uuid requires file manager service", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) fileUUID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"` + roleID + `","image_uuid":"` + fileUUID + `"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("invalid role_ids", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"` + roleID + `","role_ids":["bad"]}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid timezone", func(t *testing.T) { repo := newMemUserRepo() svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","timezone":"UTC+7","role_id":"` + roleID + `"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("create service error", func(t *testing.T) { repo := newMemUserRepo() repo.createErr = errors.New("db error") svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Post("/api/v1/users/create", h.Create) roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","attributes":{"email":"u@e.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"` + roleID + `"}}}`) req, _ := http.NewRequest(http.MethodPost, "/api/v1/users/create", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) } func TestUserEventsStream(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/users/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: "avatar.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 TestUserHandler_Update(t *testing.T) { repo := newMemUserRepo() userID := uuidv7.MustBytes() roleID := uuidv7.MustBytes() repo.users[string(userID)] = &user.User{ID: userID, Email: "u@e.com", FirstName: "John", LastName: "Doe", RoleID: roleID} svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Patch("/api/v1/users/update/:uuid", h.Update) t.Run("success", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":"Jane"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("empty image_uuid clears existing attachment", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) imageID := uuidv7.MustBytes() repo.users[string(userID)].ProfileAttachmentID = append([]byte(nil), imageID...) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":"Jane","image_uuid":""}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.users[string(userID)].ProfileAttachmentID != nil { t.Fatalf("expected profile attachment to be cleared") } }) t.Run("null image_uuid clears existing attachment", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) imageID := uuidv7.MustBytes() repo.users[string(userID)].ProfileAttachmentID = append([]byte(nil), imageID...) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":"Jane","image_uuid":null}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if repo.users[string(userID)].ProfileAttachmentID != nil { t.Fatalf("expected profile attachment to be cleared on null image_uuid") } }) t.Run("invalid id", func(t *testing.T) { req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/not-uuid", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid json", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader([]byte(`{"data":`))) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("id mismatch", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) other, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","id":"` + other + `","attributes":{"first_name":"Jane"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("id required", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"","attributes":{"first_name":"Jane"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("no attrs", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("invalid image_uuid requires file manager and returns bad request", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"image_uuid":"bad"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("image_uuid requires file manager service", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) fileUUID, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"image_uuid":"` + fileUUID + `"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("image_uuid accepts existing attachment id", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) attachmentID := uuidv7.MustBytes() attachmentIDStr, _ := uuidv7.BytesToString(attachmentID) h.WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(_ context.Context, id []byte) (*filemanager.Attachment, error) { if string(id) == string(attachmentID) { return &filemanager.Attachment{ID: attachmentID, FileID: uuidv7.MustBytes()}, nil } return nil, nil }, }) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"image_uuid":"` + attachmentIDStr + `"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if string(repo.users[string(userID)].ProfileAttachmentID) != string(attachmentID) { t.Fatalf("expected profile_attachment_id updated with attachment id") } }) t.Run("image_uuid file uuid replaces existing profile attachment", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) oldAttachmentID := uuidv7.MustBytes() newAttachmentID := uuidv7.MustBytes() newFileID := uuidv7.MustBytes() newFileIDStr, _ := uuidv7.BytesToString(newFileID) repo.users[string(userID)].ProfileAttachmentID = append([]byte(nil), oldAttachmentID...) h.WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{ getAttachmentFn: func(_ context.Context, id []byte) (*filemanager.Attachment, error) { // Incoming image_uuid is file UUID, not attachment UUID. if string(id) == string(newFileID) { return nil, nil } return nil, nil }, listAttachmentsFn: func(_ context.Context, _ filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) { return []filemanager.Attachment{{ID: oldAttachmentID, FileID: uuidv7.MustBytes()}}, 1, nil }, deleteAttachmentFn: func(_ context.Context, id []byte) error { if string(id) != string(oldAttachmentID) { t.Fatalf("unexpected deleted attachment id") } return nil }, createAttachmentFn: func(_ context.Context, p filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) { if string(p.FileID) != string(newFileID) { t.Fatalf("expected create attachment from new file id") } return &filemanager.Attachment{ID: newAttachmentID, FileID: newFileID}, nil }, }) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"image_uuid":"` + newFileIDStr + `"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body)) } if string(repo.users[string(userID)].ProfileAttachmentID) != string(newAttachmentID) { t.Fatalf("expected profile_attachment_id replaced with new attachment id") } }) t.Run("email empty", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"email":" "}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("first_name empty", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":" "}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("last_name empty", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"last_name":" "}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("update error", func(t *testing.T) { repo.updateErr = errors.New("db error") idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":"X"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } repo.updateErr = nil }) t.Run("invalid timezone", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"timezone":"UTC+7"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("update timezone success", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"timezone":"Asia/Jakarta"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if got := repo.users[string(userID)].Timezone; got != "Asia/Jakarta" { t.Fatalf("expected timezone updated, got %q", got) } }) t.Run("not found", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(uuidv7.MustBytes()) payload := []byte(`{"data":{"type":"user","id":"` + idStr + `","attributes":{"first_name":"Jane"}}}`) req, _ := http.NewRequest(http.MethodPatch, "/api/v1/users/update/"+idStr, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) } func TestUserHandler_DeleteGetListDatatable(t *testing.T) { repo := newMemUserRepo() roleID := uuidv7.MustBytes() userID := uuidv7.MustBytes() repo.users[string(userID)] = &user.User{ID: userID, Email: "u@e.com", FirstName: "John", LastName: "Doe", RoleID: roleID} repo.listUsers = []user.User{{ID: userID, Email: "u@e.com", FirstName: "John", LastName: "Doe", RoleID: roleID}} repo.listTotal = 1 svc := service.NewUserService(repo) h := NewUserHandler(svc) app := fiber.New() app.Delete("/api/v1/users/delete/:uuid", h.Delete) app.Get("/api/v1/users/get/:uuid", h.Get) app.Get("/api/v1/users/get-all", h.List) app.Get("/api/v1/users/get-all/dt", h.ListDatatable) t.Run("delete invalid id", func(t *testing.T) { req, _ := http.NewRequest(http.MethodDelete, "/api/v1/users/delete/not-uuid", nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) t.Run("delete success", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(userID) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/users/delete/"+idStr, nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("delete service error", func(t *testing.T) { failingID := uuidv7.MustBytes() repo.users[string(failingID)] = &user.User{ID: failingID, Email: "fail@e.com", RoleID: roleID} repo.deleteErr = errors.New("delete failed") defer func() { repo.deleteErr = nil }() idStr, _ := uuidv7.BytesToString(failingID) req, _ := http.NewRequest(http.MethodDelete, "/api/v1/users/delete/"+idStr, nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("get not found", func(t *testing.T) { idStr, _ := uuidv7.BytesToString(uuidv7.MustBytes()) req, _ := http.NewRequest(http.MethodGet, "/api/v1/users/get/"+idStr, nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } }) t.Run("get success", func(t *testing.T) { id := uuidv7.MustBytes() repo.users[string(id)] = &user.User{ID: id, Email: "a@b.com", FirstName: "A", LastName: "B", RoleID: roleID} idStr, _ := uuidv7.BytesToString(id) req, _ := http.NewRequest(http.MethodGet, "/api/v1/users/get/"+idStr, nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("list success", func(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/api/v1/users/get-all?page[number]=1&page[size]=10&filter[search]=u", nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) t.Run("list error", func(t *testing.T) { repo.listErr = errors.New("db error") req, _ := http.NewRequest(http.MethodGet, "/api/v1/users/get-all", nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } repo.listErr = nil }) t.Run("datatable success", func(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/api/v1/users/get-all/dt?page=1&size=10&search=u", nil) resp, _ := app.Test(req) if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } }) } func TestUserResource(t *testing.T) { now := time.Now() primaryRoleID := uuidv7.MustBytes() extraRoleID := uuidv7.MustBytes() u := &user.User{ ID: uuidv7.MustBytes(), Email: "u@e.com", FirstName: "John", LastName: "Doe", MobilePhone: "08123", ProfileAttachmentID: uuidv7.MustBytes(), RoleID: primaryRoleID, RoleName: "staff", RoleDescription: "Staff role", RoleIDs: [][]byte{primaryRoleID, extraRoleID}, Roles: []user.RoleSummary{ {ID: primaryRoleID, Name: "staff", Description: "Staff role"}, {ID: extraRoleID, Name: "chief_pilot", Description: "Chief Pilot role"}, }, CreatedBy: uuidv7.MustBytes(), EmailVerifiedAt: &now, } h := NewUserHandler(service.NewUserService(newMemUserRepo())) r := h.userResource(context.Background(), u) if r.Type != "user" || r.Attributes.Role.Name != "staff" || r.Attributes.EmailVerifiedAt == "" { t.Fatalf("unexpected user resource: %#v", r) } if len(r.Attributes.RoleIDs) == 0 || len(r.Attributes.Roles) == 0 { t.Fatalf("expected role_ids and roles in response: %#v", r.Attributes) } if r.Attributes.Timezone != "UTC" { t.Fatalf("expected default timezone UTC, got %q", r.Attributes.Timezone) } if r.Attributes.CreatedBy == "" { t.Fatalf("expected created_by in response") } if r.Attributes.ProfilePicture == nil || r.Attributes.ProfilePicture.UUID == "" { t.Fatalf("expected profile_picture in response") } } type staticUserNameGetter struct { lastName string } func (g staticUserNameGetter) GetLastName(context.Context, []byte) (string, error) { return g.lastName, nil } func TestUserHandler_HelperBranches(t *testing.T) { t.Run("with setters return same handler", func(t *testing.T) { h := NewUserHandler(service.NewUserService(newMemUserRepo())) if h.WithFileManagerService(nil) != h { t.Fatalf("expected WithFileManagerService to return same handler") } if h.WithFileStorage(nil) != h { t.Fatalf("expected WithFileStorage to return same handler") } }) t.Run("resolve profile image with nil file uuid is ignored", func(t *testing.T) { h := NewUserHandler(service.NewUserService(newMemUserRepo())) app := fiber.New() app.Get("/", func(c *fiber.Ctx) error { got, handled := h.resolveProfileImageAttachmentID(c, uuidv7.MustBytes(), nil) if handled { t.Fatalf("expected nil file uuid to be ignored") } if got != nil { t.Fatalf("expected nil attachment id") } return c.SendStatus(fiber.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/", nil) if _, err := app.Test(req); err != nil { t.Fatalf("app.Test: %v", err) } }) t.Run("parse optional image_uuid from raw body", func(t *testing.T) { state := parseOptionalStringFieldFromJSONBody([]byte(`{"data":{"attributes":{"image_uuid":null}}}`), "image_uuid") if !state.Set || state.Valid { t.Fatalf("expected image_uuid null to be set+invalid, got %+v", state) } state = parseOptionalStringFieldFromJSONBody([]byte(`{"data":{"attributes":{"image_uuid":"abc"}}}`), "image_uuid") if !state.Set || !state.Valid || state.Value != "abc" { t.Fatalf("expected image_uuid string to be set+valid, got %+v", state) } state = parseOptionalStringFieldFromJSONBody([]byte(`{"data":{"attributes":{"other":"x"}}}`), "image_uuid") if state.Set { t.Fatalf("expected image_uuid absent to be unset, got %+v", state) } }) t.Run("contains role id matches only valid uuid bytes", func(t *testing.T) { roleID := uuidv7.MustBytes() if !containsRoleID([][]byte{roleID}, roleID) { t.Fatalf("expected containsRoleID to match") } if containsRoleID([][]byte{roleID}, []byte("short")) { t.Fatalf("expected invalid role id bytes to fail") } if containsRoleID([][]byte{roleID}, uuidv7.MustBytes()) { t.Fatalf("expected non-member role id to fail") } }) t.Run("wire user name getter forwards to userctx", func(t *testing.T) { prev := userctx.GlobalUserNameGetter defer func() { userctx.GlobalUserNameGetter = prev }() getter := staticUserNameGetter{lastName: "Doe"} WireUserNameGetter(getter) if userctx.GlobalUserNameGetter == nil { t.Fatalf("expected global getter configured") } got, err := userctx.GlobalUserNameGetter.GetLastName(context.Background(), uuidv7.MustBytes()) if err != nil { t.Fatalf("GetLastName: %v", err) } if got != "Doe" { t.Fatalf("expected wired getter result, got %q", got) } }) t.Run("profile picture download url resolves when storage exists", func(t *testing.T) { fileID := uuidv7.MustBytes() attachmentID := uuidv7.MustBytes() thumbnailKey := "avatars/avatar.thumb.png" u := &user.User{ ID: uuidv7.MustBytes(), Email: "u@e.com", FirstName: "John", LastName: "Doe", ProfileAttachmentID: attachmentID, ProfileAttachment: &filemanager.Attachment{ ID: attachmentID, File: &filemanager.File{ ID: fileID, Name: "avatar.png", ObjectKey: "avatars/avatar.png", ThumbnailObjectKey: &thumbnailKey, SizeBytes: 123, }, }, } h := NewUserHandler(service.NewUserService(newMemUserRepo())).WithFileStorage(&fileManagerDownloadURLStorageMock{presignedURL: "https://signed/avatar.png"}) res := h.userResource(context.Background(), u) if res.Attributes.ProfilePicture == nil || res.Attributes.ProfilePicture.DownloadURL != "https://signed/avatar.png" { t.Fatalf("expected signed profile picture download url, got %#v", res.Attributes.ProfilePicture) } if res.Attributes.ProfilePicture.ThumbnailDownloadURL != "https://signed/avatar.png" { t.Fatalf("expected signed profile picture thumbnail url, got %#v", res.Attributes.ProfilePicture) } if res.Attributes.ProfilePicture.OriginalSizeBytes == nil || *res.Attributes.ProfilePicture.OriginalSizeBytes != 123 { t.Fatalf("expected original size bytes 123, got %#v", res.Attributes.ProfilePicture) } }) }