package handlers import ( "context" "errors" "io" "net/http" "strings" "sync" "testing" "time" "github.com/gofiber/fiber/v2" "wucher/internal/domain/audit" "wucher/internal/service" "wucher/internal/shared/pkg/uuidv7" ) type mockAuditListRepo struct { mu sync.Mutex rows []audit.AuditLog total int64 err error lastFilter string lastMethods []string lastModules []string lastActions []string lastSort string lastLimit int lastOffset int } func (m *mockAuditListRepo) Create(_ context.Context, _ *audit.AuditLog) error { return nil } func (m *mockAuditListRepo) List(_ context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) { m.mu.Lock() defer m.mu.Unlock() m.lastFilter = filter m.lastMethods = append([]string(nil), methods...) m.lastModules = append([]string(nil), modules...) m.lastActions = append([]string(nil), actions...) m.lastSort = sort m.lastLimit = limit m.lastOffset = offset if m.err != nil { return nil, 0, m.err } out := make([]audit.AuditLog, len(m.rows)) copy(out, m.rows) return out, m.total, nil } func TestAuditHandler_List(t *testing.T) { repo := &mockAuditListRepo{ rows: []audit.AuditLog{ { ID: uuidv7.MustBytes(), Layer: "api", Module: "users", Action: "list", Method: "GET", Path: "/api/v1/users/get-all", StatusCode: 200, Success: true, IP: "127.0.0.1", CreatedAt: time.Now().UTC(), }, }, total: 1, } svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[search]=http&method=UPDATE&filter[module]=user&filter[action]=list&sort=-created_at&page[number]=1&page[size]=20", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) bodyStr := string(body) if !containsAll(bodyStr, `"type":"audit_log"`, `"total":1`, `"ip":"127.0.0.1"`) { t.Fatalf("unexpected body: %s", bodyStr) } repo.mu.Lock() defer repo.mu.Unlock() if repo.lastFilter != "http" { t.Fatalf("expected filter to be propagated, got %q", repo.lastFilter) } if repo.lastSort != "created_at DESC" { t.Fatalf("expected normalized sort, got %q", repo.lastSort) } if len(repo.lastMethods) != 2 || repo.lastMethods[0] != "PUT" || repo.lastMethods[1] != "PATCH" { t.Fatalf("expected method filter [PUT PATCH], got %#v", repo.lastMethods) } if len(repo.lastModules) != 1 || repo.lastModules[0] != "user" { t.Fatalf("expected module filter [user], got %#v", repo.lastModules) } if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" { t.Fatalf("expected action filter [create update delete], got %#v", repo.lastActions) } if repo.lastLimit != 0 || repo.lastOffset != 0 { t.Fatalf("unexpected paging limit=%d offset=%d", repo.lastLimit, repo.lastOffset) } } func containsAll(v string, terms ...string) bool { for i := range terms { if !strings.Contains(v, terms[i]) { return false } } return true } func TestAuditHandler_List_Branches(t *testing.T) { t.Run("fallback filter action and clamp page", func(t *testing.T) { repo := &mockAuditListRepo{ rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Action: "create", CreatedAt: time.Now().UTC()}}, total: 1, } svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[action]=create&page[number]=0&page[size]=500&sort=action", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } repo.mu.Lock() defer repo.mu.Unlock() if repo.lastFilter != "create" { t.Fatalf("expected action fallback filter, got %q", repo.lastFilter) } if repo.lastSort != "action ASC" { t.Fatalf("expected normalized sort, got %q", repo.lastSort) } if repo.lastLimit != 0 || repo.lastOffset != 0 { t.Fatalf("unexpected paging limit=%d offset=%d", repo.lastLimit, repo.lastOffset) } }) t.Run("fallback filter layer", func(t *testing.T) { repo := &mockAuditListRepo{ rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Layer: "service", CreatedAt: time.Now().UTC()}}, total: 1, } svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[layer]=service&page[number]=1&page[size]=20", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } repo.mu.Lock() defer repo.mu.Unlock() if repo.lastFilter != "service" { t.Fatalf("expected layer fallback filter, got %q", repo.lastFilter) } }) t.Run("list error", func(t *testing.T) { repo := &mockAuditListRepo{err: errors.New("list failed")} svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400, got %d", resp.StatusCode) } }) t.Run("default mutation filter applied when query is empty", func(t *testing.T) { repo := &mockAuditListRepo{ rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Action: "update", Method: "PATCH", CreatedAt: time.Now().UTC()}}, total: 1, } svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } repo.mu.Lock() defer repo.mu.Unlock() if len(repo.lastMethods) != 4 || repo.lastMethods[0] != "POST" || repo.lastMethods[1] != "PUT" || repo.lastMethods[2] != "PATCH" || repo.lastMethods[3] != "DELETE" { t.Fatalf("expected default method filter [POST PUT PATCH DELETE], got %#v", repo.lastMethods) } if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" { t.Fatalf("expected default action filter [create update delete], got %#v", repo.lastActions) } }) t.Run("invalid module query returns 422", func(t *testing.T) { repo := &mockAuditListRepo{} svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1}) h := NewAuditHandler(svc) app := fiber.New() app.Get("/api/v1/audit-logs/get-all", h.List) req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?module=bad-module", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } }) } func TestAuditLogResource_Branches(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) row := &audit.AuditLog{ ID: uuidv7.MustBytes(), RequestID: " req-1 ", ActorUserID: uuidv7.MustBytes(), Layer: " service ", Action: " create ", Method: " POST ", Path: " /api/v1/reserve-acs/get-all ", StatusCode: 201, Success: true, IP: " 127.0.0.1 ", UserAgent: " ua ", Message: " ok ", Metadata: []byte(`{"module":"auth","count":2}`), CreatedAt: now, } res := auditLogResource(row) if res.Type != "audit_log" || res.ID == "" { t.Fatalf("expected audit resource type/id") } if res.Attributes.ActorUserID == "" { t.Fatalf("expected actor_user_id to be encoded") } if res.Attributes.RequestID != "req-1" || res.Attributes.Layer != "service" || res.Attributes.Action != "create" { t.Fatalf("expected trimmed string attributes") } if res.Attributes.Module != "reserve_ac" { t.Fatalf("expected inferred module reserve_ac, got %q", res.Attributes.Module) } md, ok := res.Attributes.Metadata.(map[string]any) if !ok || md["module"] != "auth" { t.Fatalf("expected metadata map decoded, got %#v", res.Attributes.Metadata) } if res.Attributes.CreatedAt == "" { t.Fatalf("expected created_at in resource") } }