From e8a64e88c8c077d737a773459d02c1f2b91b4aab Mon Sep 17 00:00:00 2001 From: Jaden Sooklal Date: Tue, 14 Apr 2026 01:37:21 -0400 Subject: [PATCH] [HDR-57] Add benchmarks for repository and service layers - Implemented benchmarks for schedule and time log repositories to measure performance. - Created benchmarks for authentication service, including registration, login, and token validation. - Added crypto service benchmarks for encryption and decryption operations. - Developed benchmarks for the scheduler service to evaluate schedule generation under various loads. - Introduced transcript extraction benchmarks to assess performance with different PDF sizes. - Added a Docker Compose file for running benchmarks against the scheduler and transcripts services. - Included test data generation scripts for transcript benchmarks. --- Taskfile.yml | 57 ++ .../tests/benchmarks/bench_helpers.go | 145 +++ .../tests/benchmarks/e2e/auth_bench_test.go | 223 +++++ .../benchmarks/handler/auth_bench_test.go | 159 ++++ .../benchmarks/repository/auth_bench_test.go | 354 +++++++ .../repository/schedule_bench_test.go | 232 +++++ .../repository/timelog_bench_test.go | 182 ++++ .../benchmarks/service/auth_bench_test.go | 162 ++++ .../benchmarks/service/crypto_bench_test.go | 67 ++ .../components/banking-details-form.tsx | 8 +- apps/frontend/src/routeTree.gen.ts | 896 +++++++++--------- docker-compose.bench.yml | 44 + tests/benchmarks/go.mod | 3 + .../scheduler/scheduler_bench_test.go | 231 +++++ .../benchmarks/transcripts/testdata/README.md | 59 ++ .../transcripts/transcripts_bench_test.go | 164 ++++ 16 files changed, 2534 insertions(+), 452 deletions(-) create mode 100644 apps/backend/internal/tests/benchmarks/bench_helpers.go create mode 100644 apps/backend/internal/tests/benchmarks/e2e/auth_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/handler/auth_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/repository/auth_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/repository/schedule_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/repository/timelog_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/service/auth_bench_test.go create mode 100644 apps/backend/internal/tests/benchmarks/service/crypto_bench_test.go create mode 100644 docker-compose.bench.yml create mode 100644 tests/benchmarks/go.mod create mode 100644 tests/benchmarks/scheduler/scheduler_bench_test.go create mode 100644 tests/benchmarks/transcripts/testdata/README.md create mode 100644 tests/benchmarks/transcripts/transcripts_bench_test.go diff --git a/Taskfile.yml b/Taskfile.yml index b5cd5541..3e96c71f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -278,3 +278,60 @@ tasks: desc: "Show assistant availability for a schedule (usage: task visualize:availability -- )" cmds: - ./scripts/visualize.sh availability {{.CLI_ARGS}} + + # Benchmarks + bench:backend: + desc: Run backend Go benchmarks (repository, service, handler, e2e) + dir: "{{.BACKEND_DIR}}" + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=30m ./internal/tests/benchmarks/... + + bench:backend:repo: + desc: Run backend repository benchmarks only + dir: "{{.BACKEND_DIR}}" + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=30m ./internal/tests/benchmarks/repository/... + + bench:backend:service: + desc: Run backend service benchmarks only + dir: "{{.BACKEND_DIR}}" + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=10m ./internal/tests/benchmarks/service/... + + bench:backend:handler: + desc: Run backend handler benchmarks only + dir: "{{.BACKEND_DIR}}" + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=10m ./internal/tests/benchmarks/handler/... + + bench:backend:e2e: + desc: Run backend E2E benchmarks only + dir: "{{.BACKEND_DIR}}" + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=30m ./internal/tests/benchmarks/e2e/... + + bench:scheduler: + desc: Run scheduler HTTP benchmarks (requires scheduler service running on :8001) + dir: ./tests/benchmarks + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=30m ./scheduler/... + + bench:transcripts: + desc: Run transcripts HTTP benchmarks (requires transcripts service running on :8002) + dir: ./tests/benchmarks + cmds: + - go test -bench=. -benchmem -count=3 -run=^$ -timeout=30m ./transcripts/... + + bench:services: + desc: Start Python services and run HTTP benchmarks + cmds: + - docker compose -f docker-compose.bench.yml up -d --wait + - task: bench:scheduler + - task: bench:transcripts + - docker compose -f docker-compose.bench.yml down + + bench:all: + desc: Run all benchmarks (backend + scheduler + transcripts) + cmds: + - task: bench:backend + - task: bench:services diff --git a/apps/backend/internal/tests/benchmarks/bench_helpers.go b/apps/backend/internal/tests/benchmarks/bench_helpers.go new file mode 100644 index 00000000..e5eb2f56 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/bench_helpers.go @@ -0,0 +1,145 @@ +package benchmarks + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "go.uber.org/zap" + + "github.com/HDR3604/HelpDeskApp/internal/infrastructure/database" +) + +// BenchDB wraps a testcontainers PostgreSQL instance for benchmark tests. +type BenchDB struct { + DB *sql.DB + Logger *zap.Logger + TxManager database.TxManagerInterface + ctx context.Context + container *postgres.PostgresContainer +} + +var ( + sharedDB *BenchDB + sharedOnce sync.Once + sharedErr error +) + +// SharedBenchDB returns a single shared PostgreSQL container reused across all +// benchmarks in the same test binary. The container is created once (with +// migrations) on the first call and never torn down until the process exits, +// which is handled automatically by testcontainers' reaper. +func SharedBenchDB(b *testing.B) *BenchDB { + b.Helper() + sharedOnce.Do(func() { + sharedDB, sharedErr = newBenchDB() + }) + if sharedErr != nil { + b.Fatalf("shared BenchDB: %v", sharedErr) + } + return sharedDB +} + +// newBenchDB starts a PostgreSQL container and runs all migrations. +func newBenchDB() (*BenchDB, error) { + ctx := context.Background() + + _, currentFile, _, _ := runtime.Caller(0) + migrationsDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "..", "..", "migrations") + + pgContainer, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("helpdesk"), + postgres.WithUsername("helpdesk"), + postgres.WithPassword("helpdesk"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to start postgres container: %w", err) + } + + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return nil, fmt.Errorf("failed to get connection string: %w", err) + } + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := goose.SetDialect("postgres"); err != nil { + return nil, fmt.Errorf("failed to set goose dialect: %w", err) + } + if err := goose.Up(db, migrationsDir); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + logger := zap.NewNop() + txManager := database.NewTxManager(db, logger) + + return &BenchDB{ + DB: db, + Logger: logger, + TxManager: txManager, + ctx: ctx, + container: pgContainer, + }, nil +} + +// NewBenchDB starts a dedicated PostgreSQL container for a single benchmark. +// Prefer SharedBenchDB when multiple benchmarks can share one container. +func NewBenchDB(b *testing.B) *BenchDB { + b.Helper() + bdb, err := newBenchDB() + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { bdb.close() }) + return bdb +} + +func (bdb *BenchDB) close() { + if bdb.DB != nil { + _ = bdb.DB.Close() + } + if bdb.container != nil { + _ = bdb.container.Terminate(bdb.ctx) + } +} + +// DeleteAll removes rows from the given tables within an InSystemTx. +// Use this between benchmark iterations to reset state. +func (bdb *BenchDB) DeleteAll(b *testing.B, tables ...string) { + b.Helper() + err := bdb.TxManager.InSystemTx(bdb.ctx, func(tx *sql.Tx) error { + for _, t := range tables { + if _, err := tx.ExecContext(bdb.ctx, fmt.Sprintf("DELETE FROM %s", t)); err != nil { + return fmt.Errorf("delete from %s: %w", t, err) + } + } + return nil + }) + if err != nil { + b.Fatalf("DeleteAll failed: %v", err) + } +} + +// Ctx returns the background context used by the BenchDB. +func (bdb *BenchDB) Ctx() context.Context { + return bdb.ctx +} diff --git a/apps/backend/internal/tests/benchmarks/e2e/auth_bench_test.go b/apps/backend/internal/tests/benchmarks/e2e/auth_bench_test.go new file mode 100644 index 00000000..69ab5d47 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/e2e/auth_bench_test.go @@ -0,0 +1,223 @@ +package e2e_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/HDR3604/HelpDeskApp/internal/domain/auth/handler" + "github.com/HDR3604/HelpDeskApp/internal/domain/auth/service" + authDtos "github.com/HDR3604/HelpDeskApp/internal/domain/auth/types/dtos" + authRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/auth" + "github.com/HDR3604/HelpDeskApp/internal/infrastructure/database" + emailDtos "github.com/HDR3604/HelpDeskApp/internal/infrastructure/email/types/dtos" + userRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/user" + "github.com/HDR3604/HelpDeskApp/internal/middleware" + "github.com/HDR3604/HelpDeskApp/internal/tests/benchmarks" + "github.com/HDR3604/HelpDeskApp/internal/tests/mocks" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// e2eEnv holds the full-stack test environment for E2E auth benchmarks. +type e2eEnv struct { + bdb *benchmarks.BenchDB + txManager database.TxManagerInterface + router *chi.Mux + authSvc service.AuthServiceInterface + mu sync.Mutex + lastEmail string // last captured verification URL +} + +func setupE2E(b *testing.B) *e2eEnv { + b.Helper() + logger := zap.NewNop() + + bdb := benchmarks.SharedBenchDB(b) + txManager := bdb.TxManager + ctx := bdb.Ctx() + _ = ctx + + // Clean state before each E2E benchmark + _ = txManager.InSystemTx(ctx, func(tx *sql.Tx) error { + for _, t := range []string{"auth.refresh_tokens", "auth.auth_tokens", "public.email_verifications", "auth.students", "auth.users"} { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+t); err != nil { + return err + } + } + return nil + }) + + uRepo := userRepo.NewUserRepository(logger) + refreshTokenRepo := authRepo.NewRefreshTokenRepository(logger) + authTokenRepo := authRepo.NewAuthTokenRepository(logger) + + env := &e2eEnv{bdb: bdb, txManager: txManager} + + emailSender := &mocks.MockEmailSender{ + SendFn: func(_ context.Context, req emailDtos.SendEmailRequest) (*emailDtos.SendEmailResponse, error) { + if req.Template != nil { + if v, ok := req.Template.Variables["VERIFICATION_URL"]; ok { + env.mu.Lock() + env.lastEmail, _ = v.(string) + env.mu.Unlock() + } + } + return &emailDtos.SendEmailResponse{ID: "bench-msg"}, nil + }, + } + + jwtSecret := []byte("e2e-bench-secret-at-least-32-bytes!!") + authSvc := service.NewAuthService( + logger, txManager, uRepo, refreshTokenRepo, authTokenRepo, emailSender, + jwtSecret, 3600, 86400, 86400, 604800, + "http://localhost:3000", "noreply@test.com", + ) + env.authSvc = authSvc + + hdl := handler.NewAuthHandler(logger, authSvc, 3600) + + r := chi.NewRouter() + r.Route("/api/v1", func(r chi.Router) { + hdl.RegisterRoutes(r) + r.Group(func(r chi.Router) { + r.Use(middleware.JWTAuth(authSvc)) + hdl.RegisterAuthenticatedRoutes(r) + }) + }) + env.router = r + return env +} + +func (e *e2eEnv) doRequest(method, path, body, accessToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + rr := httptest.NewRecorder() + e.router.ServeHTTP(rr, req) + return rr +} + +func (e *e2eEnv) cleanup(b *testing.B) { + b.Helper() + ctx := context.Background() + err := e.txManager.InSystemTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "DELETE FROM auth.refresh_tokens"); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM auth.auth_tokens"); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "DELETE FROM auth.users") + return err + }) + if err != nil { + b.Fatalf("cleanup: %v", err) + } +} + +// --------------------------------------------------------------------------- +// BenchmarkE2E_Register — full register flow (HTTP → handler → service → DB) +// --------------------------------------------------------------------------- + +func BenchmarkE2E_Register(b *testing.B) { + env := setupE2E(b) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + email := "bench-" + uuid.New().String()[:8] + "@uwi.edu" + body := `{"first_name":"Bench","last_name":"User","email":"` + email + `","password":"BenchPass1!","role":"admin"}` + rr := env.doRequest(http.MethodPost, "/api/v1/auth/register", body, "") + if rr.Code != http.StatusCreated { + b.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkE2E_LoginWithDB — register + login (measures real bcrypt + DB) +// --------------------------------------------------------------------------- + +func BenchmarkE2E_LoginWithDB(b *testing.B) { + env := setupE2E(b) + + // Pre-register and verify a user for login benchmarking + email := "bench-login@uwi.edu" + regBody := `{"first_name":"Bench","last_name":"User","email":"` + email + `","password":"BenchPass1!","role":"admin"}` + rr := env.doRequest(http.MethodPost, "/api/v1/auth/register", regBody, "") + if rr.Code != http.StatusCreated { + b.Fatalf("register setup: %d %s", rr.Code, rr.Body.String()) + } + + // Manually verify email via DB (faster than extracting verification token) + ctx := context.Background() + err := env.txManager.InSystemTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE auth.users SET email_verified_at = NOW() WHERE email_address = $1", email) + return err + }) + if err != nil { + b.Fatalf("verify setup: %v", err) + } + + loginBody := `{"email":"` + email + `","password":"BenchPass1!"}` + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rr := env.doRequest(http.MethodPost, "/api/v1/auth/login", loginBody, "") + if rr.Code != http.StatusOK { + b.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkE2E_RefreshToken — login then refresh (token rotation with DB) +// --------------------------------------------------------------------------- + +func BenchmarkE2E_RefreshToken(b *testing.B) { + env := setupE2E(b) + + email := "bench-refresh@uwi.edu" + regBody := `{"first_name":"Bench","last_name":"User","email":"` + email + `","password":"BenchPass1!","role":"admin"}` + rr := env.doRequest(http.MethodPost, "/api/v1/auth/register", regBody, "") + if rr.Code != http.StatusCreated { + b.Fatalf("register setup: %d %s", rr.Code, rr.Body.String()) + } + + ctx := context.Background() + _ = env.txManager.InSystemTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "UPDATE auth.users SET email_verified_at = NOW() WHERE email_address = $1", email) + return err + }) + + loginBody := `{"email":"` + email + `","password":"BenchPass1!"}` + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Login to get a fresh refresh token + loginRR := env.doRequest(http.MethodPost, "/api/v1/auth/login", loginBody, "") + if loginRR.Code != http.StatusOK { + b.Fatalf("login: %d %s", loginRR.Code, loginRR.Body.String()) + } + var tokenResp authDtos.AuthTokenResponse + json.NewDecoder(loginRR.Body).Decode(&tokenResp) + + // Refresh + refreshBody := `{"refresh_token":"` + tokenResp.RefreshToken + `"}` + refreshRR := env.doRequest(http.MethodPost, "/api/v1/auth/refresh", refreshBody, "") + if refreshRR.Code != http.StatusOK { + b.Fatalf("refresh: %d %s", refreshRR.Code, refreshRR.Body.String()) + } + } +} diff --git a/apps/backend/internal/tests/benchmarks/handler/auth_bench_test.go b/apps/backend/internal/tests/benchmarks/handler/auth_bench_test.go new file mode 100644 index 00000000..dbb4aaf3 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/handler/auth_bench_test.go @@ -0,0 +1,159 @@ +package handler_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/HDR3604/HelpDeskApp/internal/domain/auth/handler" + "github.com/HDR3604/HelpDeskApp/internal/domain/auth/service" + userAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/user/aggregate" + "github.com/HDR3604/HelpDeskApp/internal/middleware" + "github.com/HDR3604/HelpDeskApp/internal/tests/mocks" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// buildRouter creates a chi router with auth routes wired to the mock service. +func buildRouter(authSvc *mocks.MockAuthService) *chi.Mux { + logger := zap.NewNop() + hdl := handler.NewAuthHandler(logger, authSvc, 3600) + + r := chi.NewRouter() + r.Route("/api/v1", func(r chi.Router) { + hdl.RegisterRoutes(r) + + r.Group(func(r chi.Router) { + r.Use(middleware.JWTAuth(authSvc)) + hdl.RegisterAuthenticatedRoutes(r) + }) + }) + return r +} + +// --------------------------------------------------------------------------- +// BenchmarkLoginHandler — measures HTTP JSON decode → service → JSON encode +// --------------------------------------------------------------------------- + +func BenchmarkLoginHandler(b *testing.B) { + authSvc := &mocks.MockAuthService{ + LoginFn: func(_ context.Context, _, _ string) (string, string, error) { + return "access-token-value", "refresh-token-value", nil + }, + } + router := buildRouter(authSvc) + body := `{"email":"bench@uwi.edu","password":"BenchPass1!"}` + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + b.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkRegisterHandler +// --------------------------------------------------------------------------- + +func BenchmarkRegisterHandler(b *testing.B) { + authSvc := &mocks.MockAuthService{ + RegisterFn: func(_ context.Context, firstName, lastName, email, _, role string) (*userAggregate.User, error) { + now := time.Now() + return &userAggregate.User{ + ID: uuid.New(), + FirstName: firstName, + LastName: lastName, + Email: email, + Role: userAggregate.Role(role), + IsActive: true, + CreatedAt: &now, + }, nil + }, + } + router := buildRouter(authSvc) + body := `{"first_name":"Bench","last_name":"User","email":"bench@uwi.edu","password":"BenchPass1!","role":"admin"}` + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + b.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkRefreshHandler +// --------------------------------------------------------------------------- + +func BenchmarkRefreshHandler(b *testing.B) { + authSvc := &mocks.MockAuthService{ + RefreshFn: func(_ context.Context, _ string) (string, string, error) { + return "new-access", "new-refresh", nil + }, + } + router := buildRouter(authSvc) + body := `{"refresh_token":"some-refresh-token"}` + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + b.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkJWTMiddleware — measures JWT validation in the middleware path +// --------------------------------------------------------------------------- + +func BenchmarkJWTMiddleware(b *testing.B) { + claims := &service.Claims{ + FirstName: "Bench", + LastName: "User", + Email: "bench@uwi.edu", + Role: "admin", + } + authSvc := &mocks.MockAuthService{ + ValidateAccessTokenFn: func(_ string) (*service.Claims, error) { + return claims, nil + }, + } + + r := chi.NewRouter() + r.Use(middleware.JWTAuth(authSvc)) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer test-token") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + b.Fatalf("expected 200, got %d", rr.Code) + } + } +} diff --git a/apps/backend/internal/tests/benchmarks/repository/auth_bench_test.go b/apps/backend/internal/tests/benchmarks/repository/auth_bench_test.go new file mode 100644 index 00000000..2311a124 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/repository/auth_bench_test.go @@ -0,0 +1,354 @@ +package repository_test + +import ( + "context" + "database/sql" + "testing" + "time" + + authAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/auth/aggregate" + userAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/user/aggregate" + authRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/auth" + userRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/user" + "github.com/HDR3604/HelpDeskApp/internal/tests/benchmarks" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// cleanAuthTables removes all rows from auth tables in the correct FK order. +func cleanAuthTables(b *testing.B, bdb *benchmarks.BenchDB) { + b.Helper() + bdb.DeleteAll(b, + "auth.refresh_tokens", + "auth.auth_tokens", + "public.email_verifications", + "auth.students", + "auth.users", + ) +} + +// seedUser creates a verified user in the DB and returns its ID. +func seedUser(b *testing.B, bdb *benchmarks.BenchDB) uuid.UUID { + b.Helper() + userID := uuid.New() + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.DefaultCost) + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := tx.ExecContext(bdb.Ctx(), + `INSERT INTO auth.users (user_id, first_name, last_name, email_address, password, role, is_active, email_verified_at) + VALUES ($1, 'Bench', 'User', $2, $3, 'admin', true, NOW())`, + userID, "bench-"+userID.String()[:8]+"@uwi.edu", string(hashed), + ) + return err + }) + if err != nil { + b.Fatalf("seedUser: %v", err) + } + return userID +} + +// --------------------------------------------------------------------------- +// User Repository Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkUserRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := userRepo.NewUserRepository(bdb.Logger) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.MinCost) + email := "bench-" + uuid.New().String()[:8] + "@uwi.edu" + user := &userAggregate.User{ + ID: uuid.New(), + FirstName: "Bench", + LastName: "User", + Email: email, + Password: string(hashed), + Role: userAggregate.Role_Admin, + IsActive: true, + } + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, user) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkUserRepo_GetByEmail(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := userRepo.NewUserRepository(bdb.Logger) + + // Seed a user to query + userID := uuid.New() + email := "bench-lookup@uwi.edu" + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.MinCost) + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := tx.ExecContext(bdb.Ctx(), + `INSERT INTO auth.users (user_id, first_name, last_name, email_address, password, role, is_active, email_verified_at) + VALUES ($1, 'Bench', 'User', $2, $3, 'admin', true, NOW())`, + userID, email, string(hashed), + ) + return err + }) + if err != nil { + b.Fatalf("seed: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.GetByEmail(bdb.Ctx(), tx, email) + return err + }) + if err != nil { + b.Fatalf("GetByEmail: %v", err) + } + } +} + +func BenchmarkUserRepo_List(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := userRepo.NewUserRepository(bdb.Logger) + + // Seed 50 users + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.MinCost) + for i := 0; i < 50; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := tx.ExecContext(bdb.Ctx(), + `INSERT INTO auth.users (user_id, first_name, last_name, email_address, password, role, is_active, email_verified_at) + VALUES ($1, 'Bench', 'User', $2, $3, 'admin', true, NOW())`, + uuid.New(), "bench-"+uuid.New().String()[:8]+"@uwi.edu", string(hashed), + ) + return err + }) + if err != nil { + b.Fatalf("seed: %v", err) + } + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.List(bdb.Ctx(), tx) + return err + }) + if err != nil { + b.Fatalf("List: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// Refresh Token Repository Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkRefreshTokenRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewRefreshTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + token, _, err := authAggregate.NewRefreshToken(userID, 86400) + if err != nil { + b.Fatalf("NewRefreshToken: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkRefreshTokenRepo_GetByTokenHash(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewRefreshTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + // Create a token to look up + token, _, err := authAggregate.NewRefreshToken(userID, 86400) + if err != nil { + b.Fatalf("NewRefreshToken: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + if err != nil { + b.Fatalf("seed: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.GetByTokenHash(bdb.Ctx(), tx, token.TokenHash) + return err + }) + if err != nil { + b.Fatalf("GetByTokenHash: %v", err) + } + } +} + +func BenchmarkRefreshTokenRepo_RevokeAllByUserID(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewRefreshTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + // Create 5 tokens per iteration to revoke + for j := 0; j < 5; j++ { + token, _, _ := authAggregate.NewRefreshToken(userID, 86400) + _ = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + } + b.StartTimer() + + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + return repo.RevokeAllByUserID(bdb.Ctx(), tx, userID) + }) + if err != nil { + b.Fatalf("RevokeAllByUserID: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// Auth Token Repository Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkAuthTokenRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewAuthTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + token, _, err := authAggregate.NewAuthToken(userID, 86400, authAggregate.AuthTokenType_EmailVerification) + if err != nil { + b.Fatalf("NewAuthToken: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkAuthTokenRepo_GetByTokenHash(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewAuthTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + token, _, err := authAggregate.NewAuthToken(userID, 86400, authAggregate.AuthTokenType_EmailVerification) + if err != nil { + b.Fatalf("NewAuthToken: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + if err != nil { + b.Fatalf("seed: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.GetByTokenHash(bdb.Ctx(), tx, token.TokenHash, authAggregate.AuthTokenType_EmailVerification) + return err + }) + if err != nil { + b.Fatalf("GetByTokenHash: %v", err) + } + } +} + +func BenchmarkAuthTokenRepo_InvalidateAllByUserID(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewAuthTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + for j := 0; j < 5; j++ { + token, _, _ := authAggregate.NewAuthToken(userID, 86400, authAggregate.AuthTokenType_EmailVerification) + _ = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, token) + return err + }) + } + b.StartTimer() + + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + return repo.InvalidateAllByUserID(bdb.Ctx(), tx, userID, authAggregate.AuthTokenType_EmailVerification) + }) + if err != nil { + b.Fatalf("InvalidateAllByUserID: %v", err) + } + } +} + +func BenchmarkAuthTokenRepo_DeleteExpired(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanAuthTables(b, bdb) + repo := authRepo.NewAuthTokenRepository(bdb.Logger) + userID := seedUser(b, bdb) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + // Create 10 expired tokens + for j := 0; j < 10; j++ { + token, _, _ := authAggregate.NewAuthToken(userID, 1, authAggregate.AuthTokenType_EmailVerification) + token.ExpiresAt = time.Now().Add(-1 * time.Hour) // already expired + _ = bdb.TxManager.InSystemTx(ctx, func(tx *sql.Tx) error { + _, err := repo.Create(ctx, tx, token) + return err + }) + } + b.StartTimer() + + err := bdb.TxManager.InSystemTx(ctx, func(tx *sql.Tx) error { + _, err := repo.DeleteExpired(ctx, tx, time.Now(), authAggregate.AuthTokenType_EmailVerification) + return err + }) + if err != nil { + b.Fatalf("DeleteExpired: %v", err) + } + } +} diff --git a/apps/backend/internal/tests/benchmarks/repository/schedule_bench_test.go b/apps/backend/internal/tests/benchmarks/repository/schedule_bench_test.go new file mode 100644 index 00000000..5064b447 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/repository/schedule_bench_test.go @@ -0,0 +1,232 @@ +package repository_test + +import ( + "database/sql" + "encoding/json" + "testing" + "time" + + scheduleAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/schedule/aggregate" + scheduleRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/schedule" + "github.com/HDR3604/HelpDeskApp/internal/tests/benchmarks" + "github.com/google/uuid" +) + +// seedSchedule creates a schedule in the DB and returns its ID. +func seedSchedule(b *testing.B, bdb *benchmarks.BenchDB, createdBy uuid.UUID) uuid.UUID { + b.Helper() + schedID := uuid.New() + assignments := `[{"assistant_id":"a1","shift_id":"s1","day_of_week":0,"start":"09:00:00","end":"12:00:00"}]` + + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := tx.ExecContext(bdb.Ctx(), + `INSERT INTO schedule.schedules (schedule_id, title, is_active, assignments, availability_metadata, created_by, effective_from) + VALUES ($1, $2, false, $3, '{}', $4, NOW())`, + schedID, "Bench Schedule "+schedID.String()[:8], assignments, createdBy, + ) + return err + }) + if err != nil { + b.Fatalf("seedSchedule: %v", err) + } + return schedID +} + +// cleanScheduleTables removes all rows from schedule and auth tables. +func cleanScheduleTables(b *testing.B, bdb *benchmarks.BenchDB) { + b.Helper() + bdb.DeleteAll(b, + "schedule.schedules", + "schedule.shift_templates", + "auth.refresh_tokens", + "auth.auth_tokens", + "public.email_verifications", + "auth.students", + "auth.users", + ) +} + +func BenchmarkScheduleRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewScheduleRepository(bdb.Logger) + userID := seedUser(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + sched, err := scheduleAggregate.NewSchedule("Bench Schedule", time.Now(), nil) + if err != nil { + b.Fatalf("NewSchedule: %v", err) + } + sched.CreatedBy = userID + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, sched) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkScheduleRepo_GetByID(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewScheduleRepository(bdb.Logger) + userID := seedUser(b, bdb) + schedID := seedSchedule(b, bdb, userID) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.GetByID(bdb.Ctx(), tx, schedID) + return err + }) + if err != nil { + b.Fatalf("GetByID: %v", err) + } + } +} + +func BenchmarkScheduleRepo_List(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewScheduleRepository(bdb.Logger) + userID := seedUser(b, bdb) + + // Seed 20 schedules + for i := 0; i < 20; i++ { + seedSchedule(b, bdb, userID) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.List(bdb.Ctx(), tx) + return err + }) + if err != nil { + b.Fatalf("List: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// Shift Template Repository Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkShiftTemplateRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewShiftTemplateRepository(bdb.Logger) + + startTime := time.Date(0, 1, 1, 9, 0, 0, 0, time.UTC) + endTime := time.Date(0, 1, 1, 12, 0, 0, 0, time.UTC) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tmpl, err := scheduleAggregate.NewShiftTemplate( + "Shift "+uuid.New().String()[:8], + 0, startTime, endTime, 2, nil, + []scheduleAggregate.CourseDemand{ + {CourseCode: "COMP 1601", TutorsRequired: 2, Weight: 1.0}, + }, + ) + if err != nil { + b.Fatalf("NewShiftTemplate: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, tmpl) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkShiftTemplateRepo_List(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewShiftTemplateRepository(bdb.Logger) + + startTime := time.Date(0, 1, 1, 9, 0, 0, 0, time.UTC) + endTime := time.Date(0, 1, 1, 12, 0, 0, 0, time.UTC) + // Seed 15 templates + for i := 0; i < 15; i++ { + tmpl, _ := scheduleAggregate.NewShiftTemplate( + "Shift "+uuid.New().String()[:8], + int32(i%5), startTime, endTime, 2, nil, + []scheduleAggregate.CourseDemand{ + {CourseCode: "COMP 1601", TutorsRequired: 2, Weight: 1.0}, + }, + ) + _ = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, tmpl) + return err + }) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.List(bdb.Ctx(), tx) + return err + }) + if err != nil { + b.Fatalf("List: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// Schedule with large assignments JSON +// --------------------------------------------------------------------------- + +func BenchmarkScheduleRepo_CreateWithLargeAssignments(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanScheduleTables(b, bdb) + repo := scheduleRepo.NewScheduleRepository(bdb.Logger) + userID := seedUser(b, bdb) + + // Build a large assignments JSON array (50 assignments) + type assignment struct { + AssistantID string `json:"assistant_id"` + ShiftID string `json:"shift_id"` + DayOfWeek int `json:"day_of_week"` + Start string `json:"start"` + End string `json:"end"` + } + assignments := make([]assignment, 50) + for i := range assignments { + assignments[i] = assignment{ + AssistantID: uuid.New().String(), + ShiftID: uuid.New().String(), + DayOfWeek: i % 5, + Start: "09:00:00", + End: "12:00:00", + } + } + assignmentsJSON, _ := json.Marshal(assignments) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + sched, _ := scheduleAggregate.NewSchedule("Large Schedule", time.Now(), nil) + sched.CreatedBy = userID + sched.Assignments = json.RawMessage(assignmentsJSON) + + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, sched) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} diff --git a/apps/backend/internal/tests/benchmarks/repository/timelog_bench_test.go b/apps/backend/internal/tests/benchmarks/repository/timelog_bench_test.go new file mode 100644 index 00000000..b88e94c9 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/repository/timelog_bench_test.go @@ -0,0 +1,182 @@ +package repository_test + +import ( + "database/sql" + "testing" + + timelogAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/timelog/aggregate" + timelogRepository "github.com/HDR3604/HelpDeskApp/internal/domain/timelog/repository" + timelogRepo "github.com/HDR3604/HelpDeskApp/internal/infrastructure/timelog" + "github.com/HDR3604/HelpDeskApp/internal/tests/benchmarks" + "github.com/google/uuid" +) + +const benchStudentID int32 = 816000001 + +// seedStudent creates a student record required as FK for time_logs. +func seedStudent(b *testing.B, bdb *benchmarks.BenchDB) { + b.Helper() + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := tx.ExecContext(bdb.Ctx(), + `INSERT INTO auth.students (student_id, email_address, first_name, last_name, phone_number, transcript_metadata, availability) + VALUES ($1, 'bench.student@my.uwi.edu', 'Bench', 'Student', '1234567890', '{}', '{}') + ON CONFLICT DO NOTHING`, + benchStudentID, + ) + return err + }) + if err != nil { + b.Fatalf("seedStudent: %v", err) + } +} + +func seedTimeLog(b *testing.B, bdb *benchmarks.BenchDB, repo timelogRepository.TimeLogRepositoryInterface) uuid.UUID { + b.Helper() + tl, err := timelogAggregate.NewTimeLog(benchStudentID, -61.5, 10.6, 5.0) + if err != nil { + b.Fatalf("NewTimeLog: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + created, err := repo.Create(bdb.Ctx(), tx, tl) + if err != nil { + return err + } + tl = created + return nil + }) + if err != nil { + b.Fatalf("seedTimeLog: %v", err) + } + return tl.ID +} + +// --------------------------------------------------------------------------- +// TimeLog Repository Benchmarks +// --------------------------------------------------------------------------- + +// cleanTimeLogTables removes all rows from timelog and auth tables. +func cleanTimeLogTables(b *testing.B, bdb *benchmarks.BenchDB) { + b.Helper() + bdb.DeleteAll(b, + "schedule.time_logs", + "schedule.schedules", + "auth.refresh_tokens", + "auth.auth_tokens", + "public.email_verifications", + "auth.students", + "auth.users", + ) +} + +func BenchmarkTimeLogRepo_Create(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanTimeLogTables(b, bdb) + repo := timelogRepo.NewTimeLogRepository(bdb.Logger) + seedStudent(b, bdb) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + tl, err := timelogAggregate.NewTimeLog(benchStudentID, -61.5, 10.6, 5.0) + if err != nil { + b.Fatalf("NewTimeLog: %v", err) + } + err = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + // Close any existing open log so the unique constraint is satisfied + _, _ = tx.ExecContext(bdb.Ctx(), + `UPDATE schedule.time_logs SET exit_at = NOW() WHERE student_id = $1 AND exit_at IS NULL`, + benchStudentID, + ) + _, err := repo.Create(bdb.Ctx(), tx, tl) + return err + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + } +} + +func BenchmarkTimeLogRepo_GetByID(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanTimeLogTables(b, bdb) + repo := timelogRepo.NewTimeLogRepository(bdb.Logger) + seedStudent(b, bdb) + logID := seedTimeLog(b, bdb, repo) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.GetByID(bdb.Ctx(), tx, logID) + return err + }) + if err != nil { + b.Fatalf("GetByID: %v", err) + } + } +} + +func BenchmarkTimeLogRepo_List(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanTimeLogTables(b, bdb) + repo := timelogRepo.NewTimeLogRepository(bdb.Logger) + seedStudent(b, bdb) + + // Seed 100 time log entries + for i := 0; i < 100; i++ { + tl, _ := timelogAggregate.NewTimeLog(benchStudentID, -61.5, 10.6, float64(i)) + _ = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, tl) + return err + }) + } + + filter := timelogRepository.TimeLogFilter{ + Page: 1, + PerPage: 25, + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, _, err := repo.List(bdb.Ctx(), tx, filter) + return err + }) + if err != nil { + b.Fatalf("List: %v", err) + } + } +} + +func BenchmarkTimeLogRepo_ListWithStudentDetails(b *testing.B) { + bdb := benchmarks.SharedBenchDB(b) + cleanTimeLogTables(b, bdb) + repo := timelogRepo.NewTimeLogRepository(bdb.Logger) + seedStudent(b, bdb) + + for i := 0; i < 50; i++ { + tl, _ := timelogAggregate.NewTimeLog(benchStudentID, -61.5, 10.6, float64(i)) + _ = bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, err := repo.Create(bdb.Ctx(), tx, tl) + return err + }) + } + + filter := timelogRepository.TimeLogFilter{ + Page: 1, + PerPage: 25, + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := bdb.TxManager.InSystemTx(bdb.Ctx(), func(tx *sql.Tx) error { + _, _, err := repo.ListWithStudentDetails(bdb.Ctx(), tx, filter) + return err + }) + if err != nil { + b.Fatalf("ListWithStudentDetails: %v", err) + } + } +} diff --git a/apps/backend/internal/tests/benchmarks/service/auth_bench_test.go b/apps/backend/internal/tests/benchmarks/service/auth_bench_test.go new file mode 100644 index 00000000..df34dace --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/service/auth_bench_test.go @@ -0,0 +1,162 @@ +package service_test + +import ( + "context" + "database/sql" + "testing" + "time" + + authAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/auth/aggregate" + "github.com/HDR3604/HelpDeskApp/internal/domain/auth/service" + userAggregate "github.com/HDR3604/HelpDeskApp/internal/domain/user/aggregate" + emailDtos "github.com/HDR3604/HelpDeskApp/internal/infrastructure/email/types/dtos" + "github.com/HDR3604/HelpDeskApp/internal/tests/mocks" + "github.com/google/uuid" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" +) + +// buildAuthService wires the auth service with mocks that return successfully. +// Individual benchmarks override the specific mock Fn they need. +func buildAuthService() (service.AuthServiceInterface, *mocks.MockUserRepository, *mocks.MockRefreshTokenRepository, *mocks.MockAuthTokenRepository) { + logger := zap.NewNop() + txMgr := &mocks.StubTxManager{} + + userRepo := &mocks.MockUserRepository{} + refreshRepo := &mocks.MockRefreshTokenRepository{} + authTokenRepo := &mocks.MockAuthTokenRepository{} + emailSender := &mocks.MockEmailSender{ + SendFn: func(_ context.Context, _ emailDtos.SendEmailRequest) (*emailDtos.SendEmailResponse, error) { + return &emailDtos.SendEmailResponse{ID: "mock"}, nil + }, + } + + svc := service.NewAuthService( + logger, txMgr, userRepo, refreshRepo, authTokenRepo, emailSender, + []byte("benchmark-secret-at-least-32-bytes!!"), + 3600, // accessTokenTTL + 86400, // refreshTokenTTL + 86400, // verificationTokenTTL + 604800, // onboardingTokenTTL + "http://localhost:3000", + "noreply@test.com", + ) + return svc, userRepo, refreshRepo, authTokenRepo +} + +// --------------------------------------------------------------------------- +// BenchmarkAuthService_Register — exercises bcrypt hashing (CPU-intensive) +// --------------------------------------------------------------------------- + +func BenchmarkAuthService_Register(b *testing.B) { + svc, userRepo, _, authTokenRepo := buildAuthService() + + userRepo.CreateFn = func(_ context.Context, _ *sql.Tx, u *userAggregate.User) (*userAggregate.User, error) { + u.CreatedAt = ptrTime(time.Now()) + return u, nil + } + userRepo.GetByEmailFn = func(_ context.Context, _ *sql.Tx, _ string) (*userAggregate.User, error) { + return nil, nil // no existing user + } + authTokenRepo.CreateFn = func(_ context.Context, _ *sql.Tx, t *authAggregate.AuthToken) (*authAggregate.AuthToken, error) { + return t, nil + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + email := "bench-" + uuid.New().String()[:8] + "@uwi.edu" + _, err := svc.Register(context.Background(), "Bench", "User", email, "BenchPass1!", "admin") + if err != nil { + b.Fatalf("Register: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkAuthService_Login — exercises bcrypt verification + JWT signing +// --------------------------------------------------------------------------- + +func BenchmarkAuthService_Login(b *testing.B) { + svc, userRepo, refreshRepo, _ := buildAuthService() + + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.DefaultCost) + now := time.Now() + benchUser := &userAggregate.User{ + ID: uuid.New(), + FirstName: "Bench", + LastName: "User", + Email: "bench@uwi.edu", + Password: string(hashed), + Role: userAggregate.Role_Admin, + IsActive: true, + EmailVerifiedAt: &now, + } + + userRepo.GetByEmailFn = func(_ context.Context, _ *sql.Tx, _ string) (*userAggregate.User, error) { + return benchUser, nil + } + userRepo.GetStudentIDByEmailFn = func(_ context.Context, _ *sql.Tx, _ string) (*string, error) { + return nil, nil + } + refreshRepo.CreateFn = func(_ context.Context, _ *sql.Tx, t *authAggregate.RefreshToken) (*authAggregate.RefreshToken, error) { + return t, nil + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, err := svc.Login(context.Background(), "bench@uwi.edu", "BenchPass1!") + if err != nil { + b.Fatalf("Login: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// BenchmarkAuthService_ValidateAccessToken — JWT parsing only +// --------------------------------------------------------------------------- + +func BenchmarkAuthService_ValidateAccessToken(b *testing.B) { + svc, userRepo, refreshRepo, _ := buildAuthService() + + // Generate a valid token by doing a login first + hashed, _ := bcrypt.GenerateFromPassword([]byte("BenchPass1!"), bcrypt.MinCost) // MinCost for setup speed + now := time.Now() + benchUser := &userAggregate.User{ + ID: uuid.New(), + FirstName: "Bench", + LastName: "User", + Email: "bench@uwi.edu", + Password: string(hashed), + Role: userAggregate.Role_Admin, + IsActive: true, + EmailVerifiedAt: &now, + } + + userRepo.GetByEmailFn = func(_ context.Context, _ *sql.Tx, _ string) (*userAggregate.User, error) { + return benchUser, nil + } + userRepo.GetStudentIDByEmailFn = func(_ context.Context, _ *sql.Tx, _ string) (*string, error) { + return nil, nil + } + refreshRepo.CreateFn = func(_ context.Context, _ *sql.Tx, t *authAggregate.RefreshToken) (*authAggregate.RefreshToken, error) { + return t, nil + } + + accessToken, _, err := svc.Login(context.Background(), "bench@uwi.edu", "BenchPass1!") + if err != nil { + b.Fatalf("Login setup: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := svc.ValidateAccessToken(accessToken) + if err != nil { + b.Fatalf("ValidateAccessToken: %v", err) + } + } +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/apps/backend/internal/tests/benchmarks/service/crypto_bench_test.go b/apps/backend/internal/tests/benchmarks/service/crypto_bench_test.go new file mode 100644 index 00000000..3420c191 --- /dev/null +++ b/apps/backend/internal/tests/benchmarks/service/crypto_bench_test.go @@ -0,0 +1,67 @@ +package service_test + +import ( + "crypto/rand" + "testing" + + "github.com/HDR3604/HelpDeskApp/internal/infrastructure/crypto" +) + +func BenchmarkCrypto_Encrypt(b *testing.B) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + b.Fatalf("generate key: %v", err) + } + plaintext := "1234-5678-9012-3456" // typical bank account number + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := crypto.Encrypt(plaintext, key) + if err != nil { + b.Fatalf("Encrypt: %v", err) + } + } +} + +func BenchmarkCrypto_Decrypt(b *testing.B) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + b.Fatalf("generate key: %v", err) + } + plaintext := "1234-5678-9012-3456" + ciphertext, err := crypto.Encrypt(plaintext, key) + if err != nil { + b.Fatalf("setup Encrypt: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := crypto.Decrypt(ciphertext, key) + if err != nil { + b.Fatalf("Decrypt: %v", err) + } + } +} + +func BenchmarkCrypto_RoundTrip(b *testing.B) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + b.Fatalf("generate key: %v", err) + } + plaintext := "1234-5678-9012-3456" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ct, err := crypto.Encrypt(plaintext, key) + if err != nil { + b.Fatalf("Encrypt: %v", err) + } + _, err = crypto.Decrypt(ct, key) + if err != nil { + b.Fatalf("Decrypt: %v", err) + } + } +} diff --git a/apps/frontend/src/features/student/components/banking-details-form.tsx b/apps/frontend/src/features/student/components/banking-details-form.tsx index 8bdcf2b9..60944004 100644 --- a/apps/frontend/src/features/student/components/banking-details-form.tsx +++ b/apps/frontend/src/features/student/components/banking-details-form.tsx @@ -249,10 +249,10 @@ export function BankingDetailsForm({ {field.value ? (TT_BANKS.find( - (b) => - b === - field.value, - ) ?? field.value) + (b) => + b === + field.value, + ) ?? field.value) : 'Select a bank...'} diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 86f65bd8..7b8781a7 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -36,562 +36,562 @@ import { Route as AppAssistantsTimeLogsRouteImport } from './routes/_app/assista import { Route as AppAssistantsPaymentsRouteImport } from './routes/_app/assistants/payments' const DeactivatedRoute = DeactivatedRouteImport.update({ - id: '/deactivated', - path: '/deactivated', - getParentRoute: () => rootRouteImport, + id: '/deactivated', + path: '/deactivated', + getParentRoute: () => rootRouteImport, } as any) const AuthRoute = AuthRouteImport.update({ - id: '/_auth', - getParentRoute: () => rootRouteImport, + id: '/_auth', + getParentRoute: () => rootRouteImport, } as any) const AppRoute = AppRouteImport.update({ - id: '/_app', - getParentRoute: () => rootRouteImport, + id: '/_app', + getParentRoute: () => rootRouteImport, } as any) const AppIndexRoute = AppIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppRoute, + id: '/', + path: '/', + getParentRoute: () => AppRoute, } as any) const AuthSignUpRoute = AuthSignUpRouteImport.update({ - id: '/sign-up', - path: '/sign-up', - getParentRoute: () => AuthRoute, + id: '/sign-up', + path: '/sign-up', + getParentRoute: () => AuthRoute, } as any) const AuthSignInRoute = AuthSignInRouteImport.update({ - id: '/sign-in', - path: '/sign-in', - getParentRoute: () => AuthRoute, + id: '/sign-in', + path: '/sign-in', + getParentRoute: () => AuthRoute, } as any) const AuthResetPasswordRoute = AuthResetPasswordRouteImport.update({ - id: '/reset-password', - path: '/reset-password', - getParentRoute: () => AuthRoute, + id: '/reset-password', + path: '/reset-password', + getParentRoute: () => AuthRoute, } as any) const AuthOnboardingRoute = AuthOnboardingRouteImport.update({ - id: '/onboarding', - path: '/onboarding', - getParentRoute: () => AuthRoute, + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => AuthRoute, } as any) const AuthForgotPasswordRoute = AuthForgotPasswordRouteImport.update({ - id: '/forgot-password', - path: '/forgot-password', - getParentRoute: () => AuthRoute, + id: '/forgot-password', + path: '/forgot-password', + getParentRoute: () => AuthRoute, } as any) const AppSettingsRoute = AppSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => AppRoute, + id: '/settings', + path: '/settings', + getParentRoute: () => AppRoute, } as any) const AppScheduleRoute = AppScheduleRouteImport.update({ - id: '/schedule', - path: '/schedule', - getParentRoute: () => AppRoute, + id: '/schedule', + path: '/schedule', + getParentRoute: () => AppRoute, } as any) const AppCompleteOnboardingRoute = AppCompleteOnboardingRouteImport.update({ - id: '/complete-onboarding', - path: '/complete-onboarding', - getParentRoute: () => AppRoute, + id: '/complete-onboarding', + path: '/complete-onboarding', + getParentRoute: () => AppRoute, } as any) const AppClockInStationRoute = AppClockInStationRouteImport.update({ - id: '/clock-in-station', - path: '/clock-in-station', - getParentRoute: () => AppRoute, + id: '/clock-in-station', + path: '/clock-in-station', + getParentRoute: () => AppRoute, } as any) const AppClockRoute = AppClockRouteImport.update({ - id: '/clock', - path: '/clock', - getParentRoute: () => AppRoute, + id: '/clock', + path: '/clock', + getParentRoute: () => AppRoute, } as any) const AppAssistantsRoute = AppAssistantsRouteImport.update({ - id: '/assistants', - path: '/assistants', - getParentRoute: () => AppRoute, + id: '/assistants', + path: '/assistants', + getParentRoute: () => AppRoute, } as any) const AppApplicationsRoute = AppApplicationsRouteImport.update({ - id: '/applications', - path: '/applications', - getParentRoute: () => AppRoute, + id: '/applications', + path: '/applications', + getParentRoute: () => AppRoute, } as any) const AppSettingsIndexRoute = AppSettingsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppSettingsRoute, + id: '/', + path: '/', + getParentRoute: () => AppSettingsRoute, } as any) const AppScheduleIndexRoute = AppScheduleIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppScheduleRoute, + id: '/', + path: '/', + getParentRoute: () => AppScheduleRoute, } as any) const AppAssistantsIndexRoute = AppAssistantsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppAssistantsRoute, + id: '/', + path: '/', + getParentRoute: () => AppAssistantsRoute, } as any) const AppSettingsSchedulerRoute = AppSettingsSchedulerRouteImport.update({ - id: '/scheduler', - path: '/scheduler', - getParentRoute: () => AppSettingsRoute, + id: '/scheduler', + path: '/scheduler', + getParentRoute: () => AppSettingsRoute, } as any) const AppSettingsPaymentRoute = AppSettingsPaymentRouteImport.update({ - id: '/payment', - path: '/payment', - getParentRoute: () => AppSettingsRoute, + id: '/payment', + path: '/payment', + getParentRoute: () => AppSettingsRoute, } as any) const AppSettingsAvailabilityRoute = AppSettingsAvailabilityRouteImport.update({ - id: '/availability', - path: '/availability', - getParentRoute: () => AppSettingsRoute, + id: '/availability', + path: '/availability', + getParentRoute: () => AppSettingsRoute, } as any) const AppScheduleScheduleIdRoute = AppScheduleScheduleIdRouteImport.update({ - id: '/$scheduleId', - path: '/$scheduleId', - getParentRoute: () => AppScheduleRoute, + id: '/$scheduleId', + path: '/$scheduleId', + getParentRoute: () => AppScheduleRoute, } as any) const AppAssistantsTimeLogsRoute = AppAssistantsTimeLogsRouteImport.update({ - id: '/time-logs', - path: '/time-logs', - getParentRoute: () => AppAssistantsRoute, + id: '/time-logs', + path: '/time-logs', + getParentRoute: () => AppAssistantsRoute, } as any) const AppAssistantsPaymentsRoute = AppAssistantsPaymentsRouteImport.update({ - id: '/payments', - path: '/payments', - getParentRoute: () => AppAssistantsRoute, + id: '/payments', + path: '/payments', + getParentRoute: () => AppAssistantsRoute, } as any) export interface FileRoutesByFullPath { - '/': typeof AppIndexRoute - '/deactivated': typeof DeactivatedRoute - '/applications': typeof AppApplicationsRoute - '/assistants': typeof AppAssistantsRouteWithChildren - '/clock': typeof AppClockRoute - '/clock-in-station': typeof AppClockInStationRoute - '/complete-onboarding': typeof AppCompleteOnboardingRoute - '/schedule': typeof AppScheduleRouteWithChildren - '/settings': typeof AppSettingsRouteWithChildren - '/forgot-password': typeof AuthForgotPasswordRoute - '/onboarding': typeof AuthOnboardingRoute - '/reset-password': typeof AuthResetPasswordRoute - '/sign-in': typeof AuthSignInRoute - '/sign-up': typeof AuthSignUpRoute - '/assistants/payments': typeof AppAssistantsPaymentsRoute - '/assistants/time-logs': typeof AppAssistantsTimeLogsRoute - '/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute - '/settings/availability': typeof AppSettingsAvailabilityRoute - '/settings/payment': typeof AppSettingsPaymentRoute - '/settings/scheduler': typeof AppSettingsSchedulerRoute - '/assistants/': typeof AppAssistantsIndexRoute - '/schedule/': typeof AppScheduleIndexRoute - '/settings/': typeof AppSettingsIndexRoute + '/': typeof AppIndexRoute + '/deactivated': typeof DeactivatedRoute + '/applications': typeof AppApplicationsRoute + '/assistants': typeof AppAssistantsRouteWithChildren + '/clock': typeof AppClockRoute + '/clock-in-station': typeof AppClockInStationRoute + '/complete-onboarding': typeof AppCompleteOnboardingRoute + '/schedule': typeof AppScheduleRouteWithChildren + '/settings': typeof AppSettingsRouteWithChildren + '/forgot-password': typeof AuthForgotPasswordRoute + '/onboarding': typeof AuthOnboardingRoute + '/reset-password': typeof AuthResetPasswordRoute + '/sign-in': typeof AuthSignInRoute + '/sign-up': typeof AuthSignUpRoute + '/assistants/payments': typeof AppAssistantsPaymentsRoute + '/assistants/time-logs': typeof AppAssistantsTimeLogsRoute + '/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute + '/settings/availability': typeof AppSettingsAvailabilityRoute + '/settings/payment': typeof AppSettingsPaymentRoute + '/settings/scheduler': typeof AppSettingsSchedulerRoute + '/assistants/': typeof AppAssistantsIndexRoute + '/schedule/': typeof AppScheduleIndexRoute + '/settings/': typeof AppSettingsIndexRoute } export interface FileRoutesByTo { - '/': typeof AppIndexRoute - '/deactivated': typeof DeactivatedRoute - '/applications': typeof AppApplicationsRoute - '/clock': typeof AppClockRoute - '/clock-in-station': typeof AppClockInStationRoute - '/complete-onboarding': typeof AppCompleteOnboardingRoute - '/forgot-password': typeof AuthForgotPasswordRoute - '/onboarding': typeof AuthOnboardingRoute - '/reset-password': typeof AuthResetPasswordRoute - '/sign-in': typeof AuthSignInRoute - '/sign-up': typeof AuthSignUpRoute - '/assistants/payments': typeof AppAssistantsPaymentsRoute - '/assistants/time-logs': typeof AppAssistantsTimeLogsRoute - '/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute - '/settings/availability': typeof AppSettingsAvailabilityRoute - '/settings/payment': typeof AppSettingsPaymentRoute - '/settings/scheduler': typeof AppSettingsSchedulerRoute - '/assistants': typeof AppAssistantsIndexRoute - '/schedule': typeof AppScheduleIndexRoute - '/settings': typeof AppSettingsIndexRoute + '/': typeof AppIndexRoute + '/deactivated': typeof DeactivatedRoute + '/applications': typeof AppApplicationsRoute + '/clock': typeof AppClockRoute + '/clock-in-station': typeof AppClockInStationRoute + '/complete-onboarding': typeof AppCompleteOnboardingRoute + '/forgot-password': typeof AuthForgotPasswordRoute + '/onboarding': typeof AuthOnboardingRoute + '/reset-password': typeof AuthResetPasswordRoute + '/sign-in': typeof AuthSignInRoute + '/sign-up': typeof AuthSignUpRoute + '/assistants/payments': typeof AppAssistantsPaymentsRoute + '/assistants/time-logs': typeof AppAssistantsTimeLogsRoute + '/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute + '/settings/availability': typeof AppSettingsAvailabilityRoute + '/settings/payment': typeof AppSettingsPaymentRoute + '/settings/scheduler': typeof AppSettingsSchedulerRoute + '/assistants': typeof AppAssistantsIndexRoute + '/schedule': typeof AppScheduleIndexRoute + '/settings': typeof AppSettingsIndexRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/_app': typeof AppRouteWithChildren - '/_auth': typeof AuthRouteWithChildren - '/deactivated': typeof DeactivatedRoute - '/_app/applications': typeof AppApplicationsRoute - '/_app/assistants': typeof AppAssistantsRouteWithChildren - '/_app/clock': typeof AppClockRoute - '/_app/clock-in-station': typeof AppClockInStationRoute - '/_app/complete-onboarding': typeof AppCompleteOnboardingRoute - '/_app/schedule': typeof AppScheduleRouteWithChildren - '/_app/settings': typeof AppSettingsRouteWithChildren - '/_auth/forgot-password': typeof AuthForgotPasswordRoute - '/_auth/onboarding': typeof AuthOnboardingRoute - '/_auth/reset-password': typeof AuthResetPasswordRoute - '/_auth/sign-in': typeof AuthSignInRoute - '/_auth/sign-up': typeof AuthSignUpRoute - '/_app/': typeof AppIndexRoute - '/_app/assistants/payments': typeof AppAssistantsPaymentsRoute - '/_app/assistants/time-logs': typeof AppAssistantsTimeLogsRoute - '/_app/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute - '/_app/settings/availability': typeof AppSettingsAvailabilityRoute - '/_app/settings/payment': typeof AppSettingsPaymentRoute - '/_app/settings/scheduler': typeof AppSettingsSchedulerRoute - '/_app/assistants/': typeof AppAssistantsIndexRoute - '/_app/schedule/': typeof AppScheduleIndexRoute - '/_app/settings/': typeof AppSettingsIndexRoute + __root__: typeof rootRouteImport + '/_app': typeof AppRouteWithChildren + '/_auth': typeof AuthRouteWithChildren + '/deactivated': typeof DeactivatedRoute + '/_app/applications': typeof AppApplicationsRoute + '/_app/assistants': typeof AppAssistantsRouteWithChildren + '/_app/clock': typeof AppClockRoute + '/_app/clock-in-station': typeof AppClockInStationRoute + '/_app/complete-onboarding': typeof AppCompleteOnboardingRoute + '/_app/schedule': typeof AppScheduleRouteWithChildren + '/_app/settings': typeof AppSettingsRouteWithChildren + '/_auth/forgot-password': typeof AuthForgotPasswordRoute + '/_auth/onboarding': typeof AuthOnboardingRoute + '/_auth/reset-password': typeof AuthResetPasswordRoute + '/_auth/sign-in': typeof AuthSignInRoute + '/_auth/sign-up': typeof AuthSignUpRoute + '/_app/': typeof AppIndexRoute + '/_app/assistants/payments': typeof AppAssistantsPaymentsRoute + '/_app/assistants/time-logs': typeof AppAssistantsTimeLogsRoute + '/_app/schedule/$scheduleId': typeof AppScheduleScheduleIdRoute + '/_app/settings/availability': typeof AppSettingsAvailabilityRoute + '/_app/settings/payment': typeof AppSettingsPaymentRoute + '/_app/settings/scheduler': typeof AppSettingsSchedulerRoute + '/_app/assistants/': typeof AppAssistantsIndexRoute + '/_app/schedule/': typeof AppScheduleIndexRoute + '/_app/settings/': typeof AppSettingsIndexRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/deactivated' - | '/applications' - | '/assistants' - | '/clock' - | '/clock-in-station' - | '/complete-onboarding' - | '/schedule' - | '/settings' - | '/forgot-password' - | '/onboarding' - | '/reset-password' - | '/sign-in' - | '/sign-up' - | '/assistants/payments' - | '/assistants/time-logs' - | '/schedule/$scheduleId' - | '/settings/availability' - | '/settings/payment' - | '/settings/scheduler' - | '/assistants/' - | '/schedule/' - | '/settings/' - fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/deactivated' - | '/applications' - | '/clock' - | '/clock-in-station' - | '/complete-onboarding' - | '/forgot-password' - | '/onboarding' - | '/reset-password' - | '/sign-in' - | '/sign-up' - | '/assistants/payments' - | '/assistants/time-logs' - | '/schedule/$scheduleId' - | '/settings/availability' - | '/settings/payment' - | '/settings/scheduler' - | '/assistants' - | '/schedule' - | '/settings' - id: - | '__root__' - | '/_app' - | '/_auth' - | '/deactivated' - | '/_app/applications' - | '/_app/assistants' - | '/_app/clock' - | '/_app/clock-in-station' - | '/_app/complete-onboarding' - | '/_app/schedule' - | '/_app/settings' - | '/_auth/forgot-password' - | '/_auth/onboarding' - | '/_auth/reset-password' - | '/_auth/sign-in' - | '/_auth/sign-up' - | '/_app/' - | '/_app/assistants/payments' - | '/_app/assistants/time-logs' - | '/_app/schedule/$scheduleId' - | '/_app/settings/availability' - | '/_app/settings/payment' - | '/_app/settings/scheduler' - | '/_app/assistants/' - | '/_app/schedule/' - | '/_app/settings/' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/deactivated' + | '/applications' + | '/assistants' + | '/clock' + | '/clock-in-station' + | '/complete-onboarding' + | '/schedule' + | '/settings' + | '/forgot-password' + | '/onboarding' + | '/reset-password' + | '/sign-in' + | '/sign-up' + | '/assistants/payments' + | '/assistants/time-logs' + | '/schedule/$scheduleId' + | '/settings/availability' + | '/settings/payment' + | '/settings/scheduler' + | '/assistants/' + | '/schedule/' + | '/settings/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/deactivated' + | '/applications' + | '/clock' + | '/clock-in-station' + | '/complete-onboarding' + | '/forgot-password' + | '/onboarding' + | '/reset-password' + | '/sign-in' + | '/sign-up' + | '/assistants/payments' + | '/assistants/time-logs' + | '/schedule/$scheduleId' + | '/settings/availability' + | '/settings/payment' + | '/settings/scheduler' + | '/assistants' + | '/schedule' + | '/settings' + id: + | '__root__' + | '/_app' + | '/_auth' + | '/deactivated' + | '/_app/applications' + | '/_app/assistants' + | '/_app/clock' + | '/_app/clock-in-station' + | '/_app/complete-onboarding' + | '/_app/schedule' + | '/_app/settings' + | '/_auth/forgot-password' + | '/_auth/onboarding' + | '/_auth/reset-password' + | '/_auth/sign-in' + | '/_auth/sign-up' + | '/_app/' + | '/_app/assistants/payments' + | '/_app/assistants/time-logs' + | '/_app/schedule/$scheduleId' + | '/_app/settings/availability' + | '/_app/settings/payment' + | '/_app/settings/scheduler' + | '/_app/assistants/' + | '/_app/schedule/' + | '/_app/settings/' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - AppRoute: typeof AppRouteWithChildren - AuthRoute: typeof AuthRouteWithChildren - DeactivatedRoute: typeof DeactivatedRoute + AppRoute: typeof AppRouteWithChildren + AuthRoute: typeof AuthRouteWithChildren + DeactivatedRoute: typeof DeactivatedRoute } declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/deactivated': { - id: '/deactivated' - path: '/deactivated' - fullPath: '/deactivated' - preLoaderRoute: typeof DeactivatedRouteImport - parentRoute: typeof rootRouteImport + interface FileRoutesByPath { + '/deactivated': { + id: '/deactivated' + path: '/deactivated' + fullPath: '/deactivated' + preLoaderRoute: typeof DeactivatedRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } + '/_app': { + id: '/_app' + path: '' + fullPath: '/' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/_app/': { + id: '/_app/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppRoute + } + '/_auth/sign-up': { + id: '/_auth/sign-up' + path: '/sign-up' + fullPath: '/sign-up' + preLoaderRoute: typeof AuthSignUpRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/sign-in': { + id: '/_auth/sign-in' + path: '/sign-in' + fullPath: '/sign-in' + preLoaderRoute: typeof AuthSignInRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/reset-password': { + id: '/_auth/reset-password' + path: '/reset-password' + fullPath: '/reset-password' + preLoaderRoute: typeof AuthResetPasswordRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/onboarding': { + id: '/_auth/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof AuthOnboardingRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/forgot-password': { + id: '/_auth/forgot-password' + path: '/forgot-password' + fullPath: '/forgot-password' + preLoaderRoute: typeof AuthForgotPasswordRouteImport + parentRoute: typeof AuthRoute + } + '/_app/settings': { + id: '/_app/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AppSettingsRouteImport + parentRoute: typeof AppRoute + } + '/_app/schedule': { + id: '/_app/schedule' + path: '/schedule' + fullPath: '/schedule' + preLoaderRoute: typeof AppScheduleRouteImport + parentRoute: typeof AppRoute + } + '/_app/complete-onboarding': { + id: '/_app/complete-onboarding' + path: '/complete-onboarding' + fullPath: '/complete-onboarding' + preLoaderRoute: typeof AppCompleteOnboardingRouteImport + parentRoute: typeof AppRoute + } + '/_app/clock-in-station': { + id: '/_app/clock-in-station' + path: '/clock-in-station' + fullPath: '/clock-in-station' + preLoaderRoute: typeof AppClockInStationRouteImport + parentRoute: typeof AppRoute + } + '/_app/clock': { + id: '/_app/clock' + path: '/clock' + fullPath: '/clock' + preLoaderRoute: typeof AppClockRouteImport + parentRoute: typeof AppRoute + } + '/_app/assistants': { + id: '/_app/assistants' + path: '/assistants' + fullPath: '/assistants' + preLoaderRoute: typeof AppAssistantsRouteImport + parentRoute: typeof AppRoute + } + '/_app/applications': { + id: '/_app/applications' + path: '/applications' + fullPath: '/applications' + preLoaderRoute: typeof AppApplicationsRouteImport + parentRoute: typeof AppRoute + } + '/_app/settings/': { + id: '/_app/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof AppSettingsIndexRouteImport + parentRoute: typeof AppSettingsRoute + } + '/_app/schedule/': { + id: '/_app/schedule/' + path: '/' + fullPath: '/schedule/' + preLoaderRoute: typeof AppScheduleIndexRouteImport + parentRoute: typeof AppScheduleRoute + } + '/_app/assistants/': { + id: '/_app/assistants/' + path: '/' + fullPath: '/assistants/' + preLoaderRoute: typeof AppAssistantsIndexRouteImport + parentRoute: typeof AppAssistantsRoute + } + '/_app/settings/scheduler': { + id: '/_app/settings/scheduler' + path: '/scheduler' + fullPath: '/settings/scheduler' + preLoaderRoute: typeof AppSettingsSchedulerRouteImport + parentRoute: typeof AppSettingsRoute + } + '/_app/settings/payment': { + id: '/_app/settings/payment' + path: '/payment' + fullPath: '/settings/payment' + preLoaderRoute: typeof AppSettingsPaymentRouteImport + parentRoute: typeof AppSettingsRoute + } + '/_app/settings/availability': { + id: '/_app/settings/availability' + path: '/availability' + fullPath: '/settings/availability' + preLoaderRoute: typeof AppSettingsAvailabilityRouteImport + parentRoute: typeof AppSettingsRoute + } + '/_app/schedule/$scheduleId': { + id: '/_app/schedule/$scheduleId' + path: '/$scheduleId' + fullPath: '/schedule/$scheduleId' + preLoaderRoute: typeof AppScheduleScheduleIdRouteImport + parentRoute: typeof AppScheduleRoute + } + '/_app/assistants/time-logs': { + id: '/_app/assistants/time-logs' + path: '/time-logs' + fullPath: '/assistants/time-logs' + preLoaderRoute: typeof AppAssistantsTimeLogsRouteImport + parentRoute: typeof AppAssistantsRoute + } + '/_app/assistants/payments': { + id: '/_app/assistants/payments' + path: '/payments' + fullPath: '/assistants/payments' + preLoaderRoute: typeof AppAssistantsPaymentsRouteImport + parentRoute: typeof AppAssistantsRoute + } } - '/_auth': { - id: '/_auth' - path: '' - fullPath: '/' - preLoaderRoute: typeof AuthRouteImport - parentRoute: typeof rootRouteImport - } - '/_app': { - id: '/_app' - path: '' - fullPath: '/' - preLoaderRoute: typeof AppRouteImport - parentRoute: typeof rootRouteImport - } - '/_app/': { - id: '/_app/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof AppIndexRouteImport - parentRoute: typeof AppRoute - } - '/_auth/sign-up': { - id: '/_auth/sign-up' - path: '/sign-up' - fullPath: '/sign-up' - preLoaderRoute: typeof AuthSignUpRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/sign-in': { - id: '/_auth/sign-in' - path: '/sign-in' - fullPath: '/sign-in' - preLoaderRoute: typeof AuthSignInRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/reset-password': { - id: '/_auth/reset-password' - path: '/reset-password' - fullPath: '/reset-password' - preLoaderRoute: typeof AuthResetPasswordRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/onboarding': { - id: '/_auth/onboarding' - path: '/onboarding' - fullPath: '/onboarding' - preLoaderRoute: typeof AuthOnboardingRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/forgot-password': { - id: '/_auth/forgot-password' - path: '/forgot-password' - fullPath: '/forgot-password' - preLoaderRoute: typeof AuthForgotPasswordRouteImport - parentRoute: typeof AuthRoute - } - '/_app/settings': { - id: '/_app/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof AppSettingsRouteImport - parentRoute: typeof AppRoute - } - '/_app/schedule': { - id: '/_app/schedule' - path: '/schedule' - fullPath: '/schedule' - preLoaderRoute: typeof AppScheduleRouteImport - parentRoute: typeof AppRoute - } - '/_app/complete-onboarding': { - id: '/_app/complete-onboarding' - path: '/complete-onboarding' - fullPath: '/complete-onboarding' - preLoaderRoute: typeof AppCompleteOnboardingRouteImport - parentRoute: typeof AppRoute - } - '/_app/clock-in-station': { - id: '/_app/clock-in-station' - path: '/clock-in-station' - fullPath: '/clock-in-station' - preLoaderRoute: typeof AppClockInStationRouteImport - parentRoute: typeof AppRoute - } - '/_app/clock': { - id: '/_app/clock' - path: '/clock' - fullPath: '/clock' - preLoaderRoute: typeof AppClockRouteImport - parentRoute: typeof AppRoute - } - '/_app/assistants': { - id: '/_app/assistants' - path: '/assistants' - fullPath: '/assistants' - preLoaderRoute: typeof AppAssistantsRouteImport - parentRoute: typeof AppRoute - } - '/_app/applications': { - id: '/_app/applications' - path: '/applications' - fullPath: '/applications' - preLoaderRoute: typeof AppApplicationsRouteImport - parentRoute: typeof AppRoute - } - '/_app/settings/': { - id: '/_app/settings/' - path: '/' - fullPath: '/settings/' - preLoaderRoute: typeof AppSettingsIndexRouteImport - parentRoute: typeof AppSettingsRoute - } - '/_app/schedule/': { - id: '/_app/schedule/' - path: '/' - fullPath: '/schedule/' - preLoaderRoute: typeof AppScheduleIndexRouteImport - parentRoute: typeof AppScheduleRoute - } - '/_app/assistants/': { - id: '/_app/assistants/' - path: '/' - fullPath: '/assistants/' - preLoaderRoute: typeof AppAssistantsIndexRouteImport - parentRoute: typeof AppAssistantsRoute - } - '/_app/settings/scheduler': { - id: '/_app/settings/scheduler' - path: '/scheduler' - fullPath: '/settings/scheduler' - preLoaderRoute: typeof AppSettingsSchedulerRouteImport - parentRoute: typeof AppSettingsRoute - } - '/_app/settings/payment': { - id: '/_app/settings/payment' - path: '/payment' - fullPath: '/settings/payment' - preLoaderRoute: typeof AppSettingsPaymentRouteImport - parentRoute: typeof AppSettingsRoute - } - '/_app/settings/availability': { - id: '/_app/settings/availability' - path: '/availability' - fullPath: '/settings/availability' - preLoaderRoute: typeof AppSettingsAvailabilityRouteImport - parentRoute: typeof AppSettingsRoute - } - '/_app/schedule/$scheduleId': { - id: '/_app/schedule/$scheduleId' - path: '/$scheduleId' - fullPath: '/schedule/$scheduleId' - preLoaderRoute: typeof AppScheduleScheduleIdRouteImport - parentRoute: typeof AppScheduleRoute - } - '/_app/assistants/time-logs': { - id: '/_app/assistants/time-logs' - path: '/time-logs' - fullPath: '/assistants/time-logs' - preLoaderRoute: typeof AppAssistantsTimeLogsRouteImport - parentRoute: typeof AppAssistantsRoute - } - '/_app/assistants/payments': { - id: '/_app/assistants/payments' - path: '/payments' - fullPath: '/assistants/payments' - preLoaderRoute: typeof AppAssistantsPaymentsRouteImport - parentRoute: typeof AppAssistantsRoute - } - } } interface AppAssistantsRouteChildren { - AppAssistantsPaymentsRoute: typeof AppAssistantsPaymentsRoute - AppAssistantsTimeLogsRoute: typeof AppAssistantsTimeLogsRoute - AppAssistantsIndexRoute: typeof AppAssistantsIndexRoute + AppAssistantsPaymentsRoute: typeof AppAssistantsPaymentsRoute + AppAssistantsTimeLogsRoute: typeof AppAssistantsTimeLogsRoute + AppAssistantsIndexRoute: typeof AppAssistantsIndexRoute } const AppAssistantsRouteChildren: AppAssistantsRouteChildren = { - AppAssistantsPaymentsRoute: AppAssistantsPaymentsRoute, - AppAssistantsTimeLogsRoute: AppAssistantsTimeLogsRoute, - AppAssistantsIndexRoute: AppAssistantsIndexRoute, + AppAssistantsPaymentsRoute: AppAssistantsPaymentsRoute, + AppAssistantsTimeLogsRoute: AppAssistantsTimeLogsRoute, + AppAssistantsIndexRoute: AppAssistantsIndexRoute, } const AppAssistantsRouteWithChildren = AppAssistantsRoute._addFileChildren( - AppAssistantsRouteChildren, + AppAssistantsRouteChildren, ) interface AppScheduleRouteChildren { - AppScheduleScheduleIdRoute: typeof AppScheduleScheduleIdRoute - AppScheduleIndexRoute: typeof AppScheduleIndexRoute + AppScheduleScheduleIdRoute: typeof AppScheduleScheduleIdRoute + AppScheduleIndexRoute: typeof AppScheduleIndexRoute } const AppScheduleRouteChildren: AppScheduleRouteChildren = { - AppScheduleScheduleIdRoute: AppScheduleScheduleIdRoute, - AppScheduleIndexRoute: AppScheduleIndexRoute, + AppScheduleScheduleIdRoute: AppScheduleScheduleIdRoute, + AppScheduleIndexRoute: AppScheduleIndexRoute, } const AppScheduleRouteWithChildren = AppScheduleRoute._addFileChildren( - AppScheduleRouteChildren, + AppScheduleRouteChildren, ) interface AppSettingsRouteChildren { - AppSettingsAvailabilityRoute: typeof AppSettingsAvailabilityRoute - AppSettingsPaymentRoute: typeof AppSettingsPaymentRoute - AppSettingsSchedulerRoute: typeof AppSettingsSchedulerRoute - AppSettingsIndexRoute: typeof AppSettingsIndexRoute + AppSettingsAvailabilityRoute: typeof AppSettingsAvailabilityRoute + AppSettingsPaymentRoute: typeof AppSettingsPaymentRoute + AppSettingsSchedulerRoute: typeof AppSettingsSchedulerRoute + AppSettingsIndexRoute: typeof AppSettingsIndexRoute } const AppSettingsRouteChildren: AppSettingsRouteChildren = { - AppSettingsAvailabilityRoute: AppSettingsAvailabilityRoute, - AppSettingsPaymentRoute: AppSettingsPaymentRoute, - AppSettingsSchedulerRoute: AppSettingsSchedulerRoute, - AppSettingsIndexRoute: AppSettingsIndexRoute, + AppSettingsAvailabilityRoute: AppSettingsAvailabilityRoute, + AppSettingsPaymentRoute: AppSettingsPaymentRoute, + AppSettingsSchedulerRoute: AppSettingsSchedulerRoute, + AppSettingsIndexRoute: AppSettingsIndexRoute, } const AppSettingsRouteWithChildren = AppSettingsRoute._addFileChildren( - AppSettingsRouteChildren, + AppSettingsRouteChildren, ) interface AppRouteChildren { - AppApplicationsRoute: typeof AppApplicationsRoute - AppAssistantsRoute: typeof AppAssistantsRouteWithChildren - AppClockRoute: typeof AppClockRoute - AppClockInStationRoute: typeof AppClockInStationRoute - AppCompleteOnboardingRoute: typeof AppCompleteOnboardingRoute - AppScheduleRoute: typeof AppScheduleRouteWithChildren - AppSettingsRoute: typeof AppSettingsRouteWithChildren - AppIndexRoute: typeof AppIndexRoute + AppApplicationsRoute: typeof AppApplicationsRoute + AppAssistantsRoute: typeof AppAssistantsRouteWithChildren + AppClockRoute: typeof AppClockRoute + AppClockInStationRoute: typeof AppClockInStationRoute + AppCompleteOnboardingRoute: typeof AppCompleteOnboardingRoute + AppScheduleRoute: typeof AppScheduleRouteWithChildren + AppSettingsRoute: typeof AppSettingsRouteWithChildren + AppIndexRoute: typeof AppIndexRoute } const AppRouteChildren: AppRouteChildren = { - AppApplicationsRoute: AppApplicationsRoute, - AppAssistantsRoute: AppAssistantsRouteWithChildren, - AppClockRoute: AppClockRoute, - AppClockInStationRoute: AppClockInStationRoute, - AppCompleteOnboardingRoute: AppCompleteOnboardingRoute, - AppScheduleRoute: AppScheduleRouteWithChildren, - AppSettingsRoute: AppSettingsRouteWithChildren, - AppIndexRoute: AppIndexRoute, + AppApplicationsRoute: AppApplicationsRoute, + AppAssistantsRoute: AppAssistantsRouteWithChildren, + AppClockRoute: AppClockRoute, + AppClockInStationRoute: AppClockInStationRoute, + AppCompleteOnboardingRoute: AppCompleteOnboardingRoute, + AppScheduleRoute: AppScheduleRouteWithChildren, + AppSettingsRoute: AppSettingsRouteWithChildren, + AppIndexRoute: AppIndexRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) interface AuthRouteChildren { - AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute - AuthOnboardingRoute: typeof AuthOnboardingRoute - AuthResetPasswordRoute: typeof AuthResetPasswordRoute - AuthSignInRoute: typeof AuthSignInRoute - AuthSignUpRoute: typeof AuthSignUpRoute + AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute + AuthOnboardingRoute: typeof AuthOnboardingRoute + AuthResetPasswordRoute: typeof AuthResetPasswordRoute + AuthSignInRoute: typeof AuthSignInRoute + AuthSignUpRoute: typeof AuthSignUpRoute } const AuthRouteChildren: AuthRouteChildren = { - AuthForgotPasswordRoute: AuthForgotPasswordRoute, - AuthOnboardingRoute: AuthOnboardingRoute, - AuthResetPasswordRoute: AuthResetPasswordRoute, - AuthSignInRoute: AuthSignInRoute, - AuthSignUpRoute: AuthSignUpRoute, + AuthForgotPasswordRoute: AuthForgotPasswordRoute, + AuthOnboardingRoute: AuthOnboardingRoute, + AuthResetPasswordRoute: AuthResetPasswordRoute, + AuthSignInRoute: AuthSignInRoute, + AuthSignUpRoute: AuthSignUpRoute, } const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const rootRouteChildren: RootRouteChildren = { - AppRoute: AppRouteWithChildren, - AuthRoute: AuthRouteWithChildren, - DeactivatedRoute: DeactivatedRoute, + AppRoute: AppRouteWithChildren, + AuthRoute: AuthRouteWithChildren, + DeactivatedRoute: DeactivatedRoute, } export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/docker-compose.bench.yml b/docker-compose.bench.yml new file mode 100644 index 00000000..1bc02b9b --- /dev/null +++ b/docker-compose.bench.yml @@ -0,0 +1,44 @@ +# docker-compose.bench.yml +# Lightweight compose file for running benchmark targets against +# the scheduler and transcripts Python services. +# +# Usage: +# docker compose -f docker-compose.bench.yml up -d +# cd tests/benchmarks && go test -bench=. -benchmem -count=3 -run=^$ ./... +# docker compose -f docker-compose.bench.yml down + +name: help-desk-bench +services: + scheduler: + build: + context: ./apps/scheduler + dockerfile: Dockerfile.dev + ports: + - "8001:8000" + volumes: + - ./apps/scheduler:/app + - /app/tmp + environment: + - LOG_LEVEL=WARNING + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/healthy')"] + interval: 5s + timeout: 3s + retries: 5 + + transcripts: + build: + context: ./apps/transcripts + dockerfile: Dockerfile.dev + ports: + - "8002:8001" + volumes: + - ./apps/transcripts:/app + - /app/tmp + environment: + - LOG_LEVEL=WARNING + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/healthy')"] + interval: 5s + timeout: 3s + retries: 5 diff --git a/tests/benchmarks/go.mod b/tests/benchmarks/go.mod new file mode 100644 index 00000000..18934da5 --- /dev/null +++ b/tests/benchmarks/go.mod @@ -0,0 +1,3 @@ +module github.com/HDR3604/HelpDeskApp/tests/benchmarks + +go 1.25.0 diff --git a/tests/benchmarks/scheduler/scheduler_bench_test.go b/tests/benchmarks/scheduler/scheduler_bench_test.go new file mode 100644 index 00000000..4584427f --- /dev/null +++ b/tests/benchmarks/scheduler/scheduler_bench_test.go @@ -0,0 +1,231 @@ +package scheduler_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + "time" +) + +func schedulerURL() string { + if u := os.Getenv("SCHEDULER_URL"); u != "" { + return u + } + return "http://localhost:8001" +} + +// --------------------------------------------------------------------------- +// Fixture types matching the scheduler API +// --------------------------------------------------------------------------- + +type availability struct { + DayOfWeek int `json:"day_of_week"` + Start string `json:"start"` + End string `json:"end"` +} + +type assistant struct { + ID string `json:"id"` + Courses []string `json:"courses"` + Availability []availability `json:"availability"` + MinHours float64 `json:"min_hours"` + MaxHours *float64 `json:"max_hours,omitempty"` + CostPerHour float64 `json:"cost_per_hour"` +} + +type courseDemand struct { + CourseCode string `json:"course_code"` + TutorsRequired int `json:"tutors_required"` + Weight float64 `json:"weight"` +} + +type shift struct { + ID string `json:"id"` + DayOfWeek int `json:"day_of_week"` + Start string `json:"start"` + End string `json:"end"` + CourseDemands []courseDemand `json:"course_demands"` + MinStaff int `json:"min_staff"` + MaxStaff *int `json:"max_staff,omitempty"` +} + +type scheduleRequest struct { + Assistants []assistant `json:"assistants"` + Shifts []shift `json:"shifts"` + SchedulerConfig *schedulerConfig `json:"scheduler_config,omitempty"` +} + +type schedulerConfig struct { + BaselineHoursTarget int `json:"baseline_hours_target"` +} + +// buildFixture creates a schedule request with n assistants and m shifts. +func buildFixture(numAssistants, numShifts int) scheduleRequest { + courses := []string{"COMP 1601", "COMP 2603", "COMP 3603", "INFO 2602", "COMP 2605"} + days := []int{0, 1, 2, 3, 4} // Mon-Fri + + assistants := make([]assistant, numAssistants) + for i := range assistants { + day := days[i%len(days)] + myCourses := []string{courses[i%len(courses)], courses[(i+1)%len(courses)]} + maxH := float64(12) + assistants[i] = assistant{ + ID: fmt.Sprintf("a%d", i), + Courses: myCourses, + Availability: []availability{ + {DayOfWeek: day, Start: "08:00:00", End: "16:00:00"}, + {DayOfWeek: (day + 2) % 5, Start: "09:00:00", End: "14:00:00"}, + }, + MinHours: 0, + MaxHours: &maxH, + CostPerHour: 0, + } + } + + shifts := make([]shift, numShifts) + for i := range shifts { + day := days[i%len(days)] + maxStaff := 4 + shifts[i] = shift{ + ID: fmt.Sprintf("s%d", i), + DayOfWeek: day, + Start: "09:00:00", + End: "12:00:00", + CourseDemands: []courseDemand{ + {CourseCode: courses[i%len(courses)], TutorsRequired: 1, Weight: 1.0}, + }, + MinStaff: 1, + MaxStaff: &maxStaff, + } + } + + return scheduleRequest{ + Assistants: assistants, + Shifts: shifts, + SchedulerConfig: &schedulerConfig{BaselineHoursTarget: 1}, + } +} + +// postSchedule sends a generate request and returns the status code. +func postSchedule(client *http.Client, baseURL string, payload []byte) (int, error) { + resp, err := client.Post(baseURL+"/api/v1/schedules/generate", "application/json", bytes.NewReader(payload)) + if err != nil { + return 0, err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp.StatusCode, nil +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkSchedulerHealthCheck(b *testing.B) { + base := schedulerURL() + client := &http.Client{Timeout: 5 * time.Second} + + // Verify service is reachable + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("scheduler not reachable at %s: %v", base, err) + } + resp.Body.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Fatalf("health check: %v", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } +} + +func BenchmarkScheduleGenerate_Small(b *testing.B) { + benchScheduleGenerate(b, 5, 5) +} + +func BenchmarkScheduleGenerate_Medium(b *testing.B) { + benchScheduleGenerate(b, 20, 15) +} + +func BenchmarkScheduleGenerate_Large(b *testing.B) { + benchScheduleGenerate(b, 50, 30) +} + +func BenchmarkScheduleGenerate_XLarge(b *testing.B) { + benchScheduleGenerate(b, 100, 50) +} + +func benchScheduleGenerate(b *testing.B, numAssistants, numShifts int) { + b.Helper() + base := schedulerURL() + client := &http.Client{Timeout: 120 * time.Second} + + // Verify reachable + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("scheduler not reachable at %s: %v", base, err) + } + resp.Body.Close() + + fixture := buildFixture(numAssistants, numShifts) + payload, err := json.Marshal(fixture) + if err != nil { + b.Fatalf("marshal: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + code, err := postSchedule(client, base, payload) + if err != nil { + b.Fatalf("post: %v", err) + } + if code != 201 { + b.Fatalf("expected 201, got %d", code) + } + } +} + +// --------------------------------------------------------------------------- +// Concurrent benchmark +// --------------------------------------------------------------------------- + +func BenchmarkScheduleGenerate_Concurrent(b *testing.B) { + base := schedulerURL() + client := &http.Client{Timeout: 120 * time.Second} + + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("scheduler not reachable at %s: %v", base, err) + } + resp.Body.Close() + + fixture := buildFixture(10, 10) + payload, _ := json.Marshal(fixture) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + localClient := &http.Client{Timeout: 120 * time.Second} + for pb.Next() { + code, err := postSchedule(localClient, base, payload) + if err != nil { + b.Errorf("post: %v", err) + return + } + if code != 201 { + b.Errorf("expected 201, got %d", code) + return + } + } + }) +} diff --git a/tests/benchmarks/transcripts/testdata/README.md b/tests/benchmarks/transcripts/testdata/README.md new file mode 100644 index 00000000..0f3c77d3 --- /dev/null +++ b/tests/benchmarks/transcripts/testdata/README.md @@ -0,0 +1,59 @@ +# Transcript Test Fixtures + +Place PDF files here for the transcript extraction benchmarks: + +- `single_page.pdf` — A 1-page UWI unofficial transcript +- `multi_page.pdf` — A 3-5 page transcript with many courses +- `large.pdf` — A 10+ page transcript (stress test) + +## Generating Fixtures + +Use the Python script in `apps/transcripts/tests/` sample text or create +synthetic PDFs with `fpdf2`: + +```bash +pip install fpdf2 +python generate_fixtures.py +``` + +The `generate_fixtures.py` script below creates sample transcripts from the +text constants in the existing Python tests: + +```python +from fpdf import FPDF + +SAMPLE_TEXT = """ +Record of: John Michael Doe +Student Number: 123456789 + +CURRENT PROGRAMME +Degree : BSc +Programme : Computer Science +Major : Computer Science +Faculty : Science and Technology + +COMP 1601 S UG Computer Programming I A+ 3.00 ... +COMP 1602 S UG Computer Programming II A 3.00 ... +COMP 2603 S UG Object Oriented Programming I B+ 3.00 ... +INFO 2602 S UG Web Programming A- 3.00 ... + +TRANSCRIPT TOTALS +Overall: 3.44 +Degree: 3.44 + +2023/2024 Semester II +""" + +def make_pdf(text: str, path: str, pages: int = 1): + pdf = FPDF() + for _ in range(pages): + pdf.add_page() + pdf.set_font("Helvetica", size=10) + for line in text.split("\\n"): + pdf.cell(0, 5, line, ln=True) + pdf.output(path) + +make_pdf(SAMPLE_TEXT, "single_page.pdf", pages=1) +make_pdf(SAMPLE_TEXT * 3, "multi_page.pdf", pages=3) +make_pdf(SAMPLE_TEXT * 10, "large.pdf", pages=10) +``` diff --git a/tests/benchmarks/transcripts/transcripts_bench_test.go b/tests/benchmarks/transcripts/transcripts_bench_test.go new file mode 100644 index 00000000..d2e84d35 --- /dev/null +++ b/tests/benchmarks/transcripts/transcripts_bench_test.go @@ -0,0 +1,164 @@ +package transcripts_test + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func transcriptsURL() string { + if u := os.Getenv("TRANSCRIPTS_URL"); u != "" { + return u + } + return "http://localhost:8002" +} + +// testdataDir returns the absolute path to the testdata directory. +func testdataDir() string { + _, f, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(f), "testdata") +} + +// postPDF sends a multipart file upload to the extract endpoint. +func postPDF(client *http.Client, baseURL string, pdfBytes []byte) (int, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("file", "transcript.pdf") + if err != nil { + return 0, fmt.Errorf("create form file: %w", err) + } + if _, err := part.Write(pdfBytes); err != nil { + return 0, fmt.Errorf("write pdf: %w", err) + } + w.Close() + + req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/transcripts/extract", &buf) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp.StatusCode, nil +} + +// loadTestPDF reads a PDF file from testdata/ or skips the benchmark. +func loadTestPDF(b *testing.B, name string) []byte { + b.Helper() + path := filepath.Join(testdataDir(), name) + data, err := os.ReadFile(path) + if err != nil { + b.Skipf("test PDF %q not found at %s (generate with generate_fixtures.go): %v", name, path, err) + } + return data +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkTranscriptsHealthCheck(b *testing.B) { + base := transcriptsURL() + client := &http.Client{Timeout: 5 * time.Second} + + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("transcripts service not reachable at %s: %v", base, err) + } + resp.Body.Close() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Fatalf("health check: %v", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } +} + +func BenchmarkExtractTranscript_SinglePage(b *testing.B) { + benchExtractTranscript(b, "single_page.pdf") +} + +func BenchmarkExtractTranscript_MultiPage(b *testing.B) { + benchExtractTranscript(b, "multi_page.pdf") +} + +func BenchmarkExtractTranscript_LargePDF(b *testing.B) { + benchExtractTranscript(b, "large.pdf") +} + +func benchExtractTranscript(b *testing.B, filename string) { + b.Helper() + base := transcriptsURL() + client := &http.Client{Timeout: 30 * time.Second} + + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("transcripts service not reachable at %s: %v", base, err) + } + resp.Body.Close() + + pdfBytes := loadTestPDF(b, filename) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + code, err := postPDF(client, base, pdfBytes) + if err != nil { + b.Fatalf("post: %v", err) + } + if code != 200 { + b.Fatalf("expected 200, got %d", code) + } + } +} + +// --------------------------------------------------------------------------- +// Concurrent benchmark +// --------------------------------------------------------------------------- + +func BenchmarkExtractTranscript_Concurrent(b *testing.B) { + base := transcriptsURL() + client := &http.Client{Timeout: 30 * time.Second} + + resp, err := client.Get(base + "/api/v1/healthy") + if err != nil { + b.Skipf("transcripts service not reachable at %s: %v", base, err) + } + resp.Body.Close() + + pdfBytes := loadTestPDF(b, "single_page.pdf") + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + localClient := &http.Client{Timeout: 30 * time.Second} + for pb.Next() { + code, err := postPDF(localClient, base, pdfBytes) + if err != nil { + b.Errorf("post: %v", err) + return + } + if code != 200 { + b.Errorf("expected 200, got %d", code) + return + } + } + }) +}