init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,856 @@
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)
}
})
}