package tests // integration_http_test.go contains backend tests for package behavior, error paths, and regressions. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/prometheus/client_golang/prometheus" appu "knowfoolery/backend/services/user-service/internal/application/user" domain "knowfoolery/backend/services/user-service/internal/domain/user" httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http" sharederrors "knowfoolery/backend/shared/domain/errors" sharedtypes "knowfoolery/backend/shared/domain/types" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) // inMemoryRepo is a lightweight repository double for HTTP integration tests. type inMemoryRepo struct { items map[string]*domain.User audit []domain.AuditLogEntry } // newInMemoryRepo creates in-memory test doubles and deterministic fixtures. func newInMemoryRepo() *inMemoryRepo { return &inMemoryRepo{items: map[string]*domain.User{}, audit: make([]domain.AuditLogEntry, 0)} } // EnsureSchema initializes schema state required before repository operations. func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil } // Create persists a new entity in the in-memory repository. func (r *inMemoryRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) { if user.ID == "" { user.ID = "user-" + strconv.Itoa(len(r.items)+1) } now := time.Now().UTC() if user.CreatedAt.IsZero() { user.CreatedAt = now } user.UpdatedAt = now r.items[user.ID] = user return user, nil } // GetByID retrieves data from the in-memory repository. func (r *inMemoryRepo) GetByID(ctx context.Context, id string) (*domain.User, error) { if u, ok := r.items[id]; ok && !u.IsDeleted() { return u, nil } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // GetByEmail retrieves data from the in-memory repository. func (r *inMemoryRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { for _, u := range r.items { if u.Email == email && !u.IsDeleted() { return u, nil } } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // GetByZitadelUserID retrieves data from the in-memory repository. func (r *inMemoryRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) { for _, u := range r.items { if u.ZitadelUserID == zid && !u.IsDeleted() { return u, nil } } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // UpdateProfile updates an existing entity in the in-memory repository. func (r *inMemoryRepo) UpdateProfile( ctx context.Context, id, displayName string, consent domain.ConsentRecord, ) (*domain.User, error) { u, err := r.GetByID(ctx, id) if err != nil { return nil, err } u.DisplayName = displayName u.ConsentVersion = consent.Version u.ConsentGivenAt = consent.GivenAt u.ConsentSource = consent.Source u.UpdatedAt = time.Now().UTC() return u, nil } // MarkEmailVerified supports mark email verified test setup and assertions. func (r *inMemoryRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) { u, err := r.GetByID(ctx, id) if err != nil { return nil, err } u.EmailVerified = true u.UpdatedAt = time.Now().UTC() return u, nil } // SoftDelete supports soft delete test setup and assertions. func (r *inMemoryRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error { u, err := r.GetByID(ctx, id) if err != nil { return err } now := time.Now().UTC() u.DeletedAt = &now r.audit = append( r.audit, domain.AuditLogEntry{ TargetUserID: id, ActorUserID: actorUserID, Action: domain.AuditActionGDPRDelete, CreatedAt: now, }, ) return nil } // List returns filtered collections from the in-memory repository. func (r *inMemoryRepo) List( ctx context.Context, pagination sharedtypes.Pagination, filter domain.ListFilter, ) ([]*domain.User, int64, error) { out := make([]*domain.User, 0) for _, u := range r.items { if !filter.IncludeDeleted && u.IsDeleted() { continue } out = append(out, u) } return out, int64(len(out)), nil } // AuditLogsByUserID supports audit logs by user id test setup and assertions. func (r *inMemoryRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) { out := make([]domain.AuditLogEntry, 0) for _, a := range r.audit { if a.TargetUserID == id { out = append(out, a) } } return out, nil } // WriteAuditLog supports write audit log test setup and assertions. func (r *inMemoryRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error { entry.CreatedAt = time.Now().UTC() r.audit = append(r.audit, entry) return nil } // setupApp wires the test application with mocked dependencies. func setupApp(t *testing.T) (*fiber.App, *inMemoryRepo) { t.Helper() repo := newInMemoryRepo() _, _ = repo.Create(context.Background(), &domain.User{ ID: "user-1", ZitadelUserID: "user-1", Email: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentGivenAt: time.Now().UTC(), ConsentSource: "web", }) _, _ = repo.Create(context.Background(), &domain.User{ ID: "user-2", ZitadelUserID: "user-2", Email: "other@example.com", DisplayName: "Player Two", ConsentVersion: "v1", ConsentGivenAt: time.Now().UTC(), ConsentSource: "web", }) svc := appu.NewService(repo) metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "user-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, 50, 200) app := fiber.New() authMW := func(c fiber.Ctx) error { auth := c.Get("Authorization") switch auth { case "Bearer user-1": c.Locals("user_id", "user-1") c.Locals("user_email", "player@example.com") c.Locals("user_roles", []string{"player"}) return c.Next() case "Bearer admin": c.Locals("user_id", "admin-1") c.Locals("user_email", "admin@example.com") c.Locals("user_roles", []string{"admin"}) return c.Next() default: return c.SendStatus(http.StatusUnauthorized) } } httpapi.RegisterRoutes(app, h, authMW, nil) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) return app, repo } // TestRegisterAndCRUD ensures register and crud behavior is handled correctly. func TestRegisterAndCRUD(t *testing.T) { app, _ := setupApp(t) payload, _ := json.Marshal(map[string]any{ "display_name": "Player One", "consent_version": "v1", "consent_source": "web", }) req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer user-1") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "register failed") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil) req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed") defer resp.Body.Close() updatePayload, _ := json.Marshal(map[string]any{ "display_name": "Player One Updated", "consent_version": "v2", "consent_source": "web", }) req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed") defer resp.Body.Close() req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil) req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "delete failed") defer resp.Body.Close() } // TestAuthGuardsAndAdminRoutes ensures auth guards and admin routes behavior is handled correctly. func TestAuthGuardsAndAdminRoutes(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil) resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil) req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req.Header.Set("Authorization", "Bearer admin") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok") defer resp.Body.Close() req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil) req.Header.Set("Authorization", "Bearer admin") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected export ok") defer resp.Body.Close() } // TestMetricsEndpoint ensures metrics endpoint behavior is handled correctly. func TestMetricsEndpoint(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed") defer resp.Body.Close() } // TestVerifyEmailAndValidationPaths ensures verify email and validation paths behavior is handled correctly. func TestVerifyEmailAndValidationPaths(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodPost, "/users/verify-email", nil) resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized verify-email") defer resp.Body.Close() req = httptest.NewRequest(http.MethodPost, "/users/verify-email", nil) req.Header.Set("Authorization", "Bearer user-1") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected verify-email success") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users?created_after=not-rfc3339", nil) req.Header.Set("Authorization", "Bearer admin") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_after validation failure") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users?created_before=not-rfc3339", nil) req.Header.Set("Authorization", "Bearer admin") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_before validation failure") defer resp.Body.Close() } // TestRegisterAndUpdateInvalidPayloads ensures register and update invalid payloads behavior is handled correctly. func TestRegisterAndUpdateInvalidPayloads(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader([]byte("{"))) req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Content-Type", "application/json") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for register") defer resp.Body.Close() invalidRegister, _ := json.Marshal(map[string]any{ "display_name": "", "consent_version": "v1", "consent_source": "web", }) req = httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(invalidRegister)) req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Content-Type", "application/json") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected validation error for register") defer resp.Body.Close() req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader([]byte("{"))) req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Content-Type", "application/json") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for update") defer resp.Body.Close() }