From 1afcee9ed7bd8a3912cef3b6d53cc97ab7ee069a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:30:00 +0000 Subject: [PATCH 1/5] Initial plan From 916c0ee77d63f6564796ec7646ba776f481d3112 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:46:02 +0000 Subject: [PATCH 2/5] Add SearchUsername method to accountrepository with trigram similarity Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- .../adapters/accountrepository/postgres.go | 68 +++++++++ .../accountrepository/postgres_test.go | 140 ++++++++++++++++++ .../6_add_trigram_extension.down.sql | 2 + .../migrations/6_add_trigram_extension.up.sql | 3 + internal/adapters/database/migrator.go | 15 +- 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/adapters/database/migrations/6_add_trigram_extension.down.sql create mode 100644 internal/adapters/database/migrations/6_add_trigram_extension.up.sql diff --git a/internal/adapters/accountrepository/postgres.go b/internal/adapters/accountrepository/postgres.go index 4a96616c..ca09d943 100644 --- a/internal/adapters/accountrepository/postgres.go +++ b/internal/adapters/accountrepository/postgres.go @@ -261,3 +261,71 @@ func (p *Postgres) GetAccountByUsername(ctx context.Context, username string) (d QueriedAt: entry.QueriedAt, }, nil } + +func (p *Postgres) SearchUsername(ctx context.Context, searchTerm string, top int) ([]string, error) { + ctx, span := p.tracer.Start(ctx, "Postgres.SearchUsername") + defer span.End() + + if top < 1 || top > 100 { + err := fmt.Errorf("top must be between 1 and 100") + reporting.Report(ctx, err, map[string]string{ + "top": fmt.Sprintf("%d", top), + }) + return nil, err + } + + // Use a transaction to set search_path to include public schema for pg_trgm functions + txx, err := p.db.BeginTxx(ctx, nil) + if err != nil { + err := fmt.Errorf("failed to start transaction: %w", err) + reporting.Report(ctx, err) + return nil, err + } + defer txx.Rollback() + + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET LOCAL search_path TO %s, public", pq.QuoteIdentifier(p.schema))) + if err != nil { + err := fmt.Errorf("failed to set search path: %w", err) + reporting.Report(ctx, err, map[string]string{ + "schema": p.schema, + }) + return nil, err + } + + // Set similarity threshold to a reasonable value for username search + // Lower threshold allows more fuzzy matching (default is 0.3) + _, err = txx.ExecContext(ctx, "SET LOCAL pg_trgm.similarity_threshold = 0.2") + if err != nil { + err := fmt.Errorf("failed to set similarity threshold: %w", err) + reporting.Report(ctx, err) + return nil, err + } + + uuids := []string{} // Initialize to empty slice, not nil + err = txx.SelectContext(ctx, &uuids, ` + SELECT player_uuid + FROM usernames + WHERE username % $1 + ORDER BY similarity(username, $1) DESC, queried_at DESC + LIMIT $2`, + searchTerm, + top, + ) + if err != nil { + err := fmt.Errorf("failed to search usernames: %w", err) + reporting.Report(ctx, err, map[string]string{ + "searchTerm": searchTerm, + "top": fmt.Sprintf("%d", top), + }) + return nil, err + } + + err = txx.Commit() + if err != nil { + err := fmt.Errorf("failed to commit transaction: %w", err) + reporting.Report(ctx, err) + return nil, err + } + + return uuids, nil +} diff --git a/internal/adapters/accountrepository/postgres_test.go b/internal/adapters/accountrepository/postgres_test.go index 793333ae..f3a5948d 100644 --- a/internal/adapters/accountrepository/postgres_test.go +++ b/internal/adapters/accountrepository/postgres_test.go @@ -731,4 +731,144 @@ func TestPostgres(t *testing.T) { require.WithinDuration(t, now.Add(-2*time.Hour), account.QueriedAt, 1*time.Millisecond) }) }) + + t.Run("SearchUsername", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accounts []domain.Account + searchTerm string + top int + expected []string + }{ + { + name: "exact match", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "testuser", QueriedAt: now}, + {UUID: makeUUID(2), Username: "anotheruser", QueriedAt: now}, + }, + searchTerm: "testuser", + top: 10, + expected: []string{makeUUID(1)}, + }, + { + name: "partial match with similarity", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "testuser", QueriedAt: now}, + {UUID: makeUUID(2), Username: "testing", QueriedAt: now}, + {UUID: makeUUID(3), Username: "tests", QueriedAt: now}, + {UUID: makeUUID(4), Username: "unrelated", QueriedAt: now}, + }, + searchTerm: "test", + top: 10, + // Based on trigram similarity: "tests" is most similar, then "testing", then "testuser" + expected: []string{makeUUID(3), makeUUID(2), makeUUID(1)}, + }, + { + name: "limit results with top", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "testuser1", QueriedAt: now}, + {UUID: makeUUID(2), Username: "testuser2", QueriedAt: now}, + {UUID: makeUUID(3), Username: "testuser3", QueriedAt: now}, + }, + searchTerm: "test", + top: 2, + expected: []string{makeUUID(1), makeUUID(2)}, + }, + { + name: "secondary sort by queried_at", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "user1", QueriedAt: now.Add(-2 * time.Hour)}, + {UUID: makeUUID(2), Username: "user2", QueriedAt: now.Add(-1 * time.Hour)}, + {UUID: makeUUID(3), Username: "user3", QueriedAt: now}, + }, + searchTerm: "user", + top: 10, + // All three have similar trigram similarity, sorted by queried_at DESC + expected: []string{makeUUID(3), makeUUID(2), makeUUID(1)}, + }, + { + name: "case insensitive search", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "TestUser", QueriedAt: now}, + {UUID: makeUUID(2), Username: "testuser", QueriedAt: now}, + }, + searchTerm: "TESTUSER", + top: 10, + // Only UUID 2 remains because storing "testuser" deletes "TestUser" (case-insensitive) + expected: []string{makeUUID(2)}, + }, + { + name: "no matches below threshold", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "apple", QueriedAt: now}, + {UUID: makeUUID(2), Username: "banana", QueriedAt: now}, + }, + searchTerm: "xyz", + top: 10, + expected: []string{}, + }, + { + name: "typo tolerance", + accounts: []domain.Account{ + {UUID: makeUUID(1), Username: "player123", QueriedAt: now}, + {UUID: makeUUID(2), Username: "plaeyr123", QueriedAt: now}, + {UUID: makeUUID(3), Username: "different", QueriedAt: now}, + }, + searchTerm: "player", + top: 10, + expected: []string{makeUUID(1), makeUUID(2)}, + }, + { + name: "empty database", + accounts: []domain.Account{}, + searchTerm: "anything", + top: 10, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + schemaName := fmt.Sprintf("search_username_%s", tt.name) + p := newPostgres(t, db, schemaName) + + // Store accounts + for _, account := range tt.accounts { + err := p.StoreAccount(ctx, account) + require.NoError(t, err) + } + + // Execute search + results, err := p.SearchUsername(ctx, tt.searchTerm, tt.top) + require.NoError(t, err) + + // Verify results + require.Equal(t, tt.expected, results, "Search results do not match expected UUIDs") + }) + } + + t.Run("invalid top value too low", func(t *testing.T) { + t.Parallel() + + p := newPostgres(t, db, "search_username_invalid_top_low") + + _, err := p.SearchUsername(ctx, "test", 0) + require.Error(t, err) + require.Contains(t, err.Error(), "top must be between 1 and 100") + }) + + t.Run("invalid top value too high", func(t *testing.T) { + t.Parallel() + + p := newPostgres(t, db, "search_username_invalid_top_high") + + _, err := p.SearchUsername(ctx, "test", 101) + require.Error(t, err) + require.Contains(t, err.Error(), "top must be between 1 and 100") + }) + }) } diff --git a/internal/adapters/database/migrations/6_add_trigram_extension.down.sql b/internal/adapters/database/migrations/6_add_trigram_extension.down.sql new file mode 100644 index 00000000..860860f6 --- /dev/null +++ b/internal/adapters/database/migrations/6_add_trigram_extension.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_usernames_username_trgm; +-- Note: Not dropping pg_trgm extension as it may be used by other schemas diff --git a/internal/adapters/database/migrations/6_add_trigram_extension.up.sql b/internal/adapters/database/migrations/6_add_trigram_extension.up.sql new file mode 100644 index 00000000..702b5c46 --- /dev/null +++ b/internal/adapters/database/migrations/6_add_trigram_extension.up.sql @@ -0,0 +1,3 @@ +-- Create GIN index on username for fast trigram similarity searches +-- Note: pg_trgm extension is created by the migrator before running migrations +CREATE INDEX IF NOT EXISTS idx_usernames_username_trgm ON usernames USING gin (username gin_trgm_ops); diff --git a/internal/adapters/database/migrator.go b/internal/adapters/database/migrator.go index bb592a4f..2d95d97a 100644 --- a/internal/adapters/database/migrator.go +++ b/internal/adapters/database/migrator.go @@ -35,6 +35,18 @@ func (m *migrator) Migrate(ctx context.Context, schemaName string) error { } func (m *migrator) migrate(ctx context.Context, schemaName string) error { + // Create pg_trgm extension if it doesn't exist (database-wide, not schema-specific) + // This must be done in a separate connection that is committed before migrations start + extensionConn, err := m.db.Conn(ctx) + if err != nil { + return fmt.Errorf("migrate: failed to connect for extension creation: %w", err) + } + _, err = extensionConn.ExecContext(ctx, "CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public") + extensionConn.Close() // Close connection to commit the extension creation + if err != nil { + return fmt.Errorf("migrate: failed to create pg_trgm extension: %w", err) + } + conn, err := m.db.Conn(ctx) if err != nil { return fmt.Errorf("migrate: failed to connect to db: %w", err) @@ -46,7 +58,8 @@ func (m *migrator) migrate(ctx context.Context, schemaName string) error { return fmt.Errorf("migrate: failed to create schema: %w", err) } - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + // Set search_path to include both the schema and public (where pg_trgm is installed) + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s, public", pq.QuoteIdentifier(schemaName))) if err != nil { return fmt.Errorf("migrate: failed to set search path: %w", err) } From a4a306d4f3235640e1cff837ba366175a2a0fa5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:47:16 +0000 Subject: [PATCH 3/5] Add SearchUsername app method with tests Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- internal/app/search_username.go | 27 ++++++ internal/app/search_username_test.go | 122 +++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/app/search_username.go create mode 100644 internal/app/search_username_test.go diff --git a/internal/app/search_username.go b/internal/app/search_username.go new file mode 100644 index 00000000..b611d74e --- /dev/null +++ b/internal/app/search_username.go @@ -0,0 +1,27 @@ +package app + +import ( + "context" + "fmt" + "time" +) + +type SearchUsername func(ctx context.Context, searchTerm string, top int) ([]string, error) + +type usernameSearcher interface { + SearchUsername(ctx context.Context, searchTerm string, top int) ([]string, error) +} + +func BuildSearchUsername(repository usernameSearcher) SearchUsername { + return func(ctx context.Context, searchTerm string, top int) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + uuids, err := repository.SearchUsername(ctx, searchTerm, top) + if err != nil { + return nil, fmt.Errorf("failed to search username: %w", err) + } + + return uuids, nil + } +} diff --git a/internal/app/search_username_test.go b/internal/app/search_username_test.go new file mode 100644 index 00000000..a916e7b7 --- /dev/null +++ b/internal/app/search_username_test.go @@ -0,0 +1,122 @@ +package app_test + +import ( + "context" + "fmt" + "testing" + + "github.com/Amund211/flashlight/internal/app" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockUsernameSearcher struct { + t *testing.T + + searchTerm string + top int + called bool + uuids []string + err error +} + +func (m *mockUsernameSearcher) SearchUsername(ctx context.Context, searchTerm string, top int) ([]string, error) { + m.t.Helper() + require.Equal(m.t, m.searchTerm, searchTerm) + require.Equal(m.t, m.top, top) + require.False(m.t, m.called, "SearchUsername should only be called once") + + m.called = true + return m.uuids, m.err +} + +func TestBuildSearchUsername(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + t.Run("successful search with results", func(t *testing.T) { + t.Parallel() + + expectedUUIDs := []string{ + "12345678-1234-1234-1234-123456789012", + "87654321-4321-4321-4321-210987654321", + } + + searcher := &mockUsernameSearcher{ + t: t, + searchTerm: "testuser", + top: 10, + uuids: expectedUUIDs, + } + + searchUsername := app.BuildSearchUsername(searcher) + + result, err := searchUsername(ctx, "testuser", 10) + require.NoError(t, err) + require.Equal(t, expectedUUIDs, result) + require.True(t, searcher.called) + }) + + t.Run("successful search with no results", func(t *testing.T) { + t.Parallel() + + searcher := &mockUsernameSearcher{ + t: t, + searchTerm: "nonexistent", + top: 5, + uuids: []string{}, + } + + searchUsername := app.BuildSearchUsername(searcher) + + result, err := searchUsername(ctx, "nonexistent", 5) + require.NoError(t, err) + require.Empty(t, result) + require.True(t, searcher.called) + }) + + t.Run("repository error", func(t *testing.T) { + t.Parallel() + + searcher := &mockUsernameSearcher{ + t: t, + searchTerm: "error", + top: 10, + err: fmt.Errorf("database error"), + } + + searchUsername := app.BuildSearchUsername(searcher) + + _, err := searchUsername(ctx, "error", 10) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to search username") + require.Contains(t, err.Error(), "database error") + require.True(t, searcher.called) + }) + + t.Run("search with different top values", func(t *testing.T) { + t.Parallel() + + for _, topValue := range []int{1, 5, 10, 50, 100} { + t.Run(fmt.Sprintf("top=%d", topValue), func(t *testing.T) { + t.Parallel() + + searcher := &mockUsernameSearcher{ + t: t, + searchTerm: "user", + top: topValue, + uuids: []string{"uuid1"}, + } + + searchUsername := app.BuildSearchUsername(searcher) + + result, err := searchUsername(ctx, "user", topValue) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "uuid1", result[0]) + require.True(t, searcher.called) + }) + } + }) +} From 227effdc49ee2da5bdfe62fff530d7618207e2e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:49:21 +0000 Subject: [PATCH 4/5] Add SearchUsername GET endpoint with tests and wire up in main.go Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- internal/ports/search_username.go | 136 +++++++++++++++++ internal/ports/search_username_test.go | 194 +++++++++++++++++++++++++ main.go | 11 ++ 3 files changed, 341 insertions(+) create mode 100644 internal/ports/search_username.go create mode 100644 internal/ports/search_username_test.go diff --git a/internal/ports/search_username.go b/internal/ports/search_username.go new file mode 100644 index 00000000..d1300c21 --- /dev/null +++ b/internal/ports/search_username.go @@ -0,0 +1,136 @@ +package ports + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/logging" + "github.com/Amund211/flashlight/internal/ratelimiting" + "github.com/Amund211/flashlight/internal/reporting" +) + +type searchUsernameResponseObject struct { + UUIDs []string `json:"uuids"` +} + +func MakeSearchUsernameHandler( + searchUsername app.SearchUsername, + rootLogger *slog.Logger, + sentryMiddleware func(http.HandlerFunc) http.HandlerFunc, +) http.HandlerFunc { + ipLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(8), + ratelimiting.BurstSize(480), + ) + ipRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + ipLimiter, + ratelimiting.IPKeyFunc, + ) + userIDLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(2), + ratelimiting.BurstSize(120), + ) + userIDRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + // NOTE: Rate limiting based on user controlled value + userIDLimiter, + ratelimiting.UserIDKeyFunc, + ) + + makeOnLimitExceeded := func(rateLimiter ratelimiting.RequestRateLimiter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + statusCode := http.StatusTooManyRequests + + logging.FromContext(ctx).InfoContext(ctx, "Rate limit exceeded", "statusCode", statusCode, "reason", "ratelimit exceeded", "key", rateLimiter.KeyFor(r)) + + http.Error(w, "Rate limit exceeded", statusCode) + } + } + + middleware := ComposeMiddlewares( + buildMetricsMiddleware("search_username"), + logging.NewRequestLoggerMiddleware(rootLogger), + sentryMiddleware, + reporting.NewAddMetaMiddleware("search_username"), + NewRateLimitMiddleware(ipRateLimiter, makeOnLimitExceeded(ipRateLimiter)), + NewRateLimitMiddleware(userIDRateLimiter, makeOnLimitExceeded(userIDRateLimiter)), + ) + + handler := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + searchTerm := r.URL.Query().Get("q") + topStr := r.URL.Query().Get("top") + + userID := r.Header.Get("X-User-Id") + if userID == "" { + userID = "" + } + ctx = reporting.SetUserIDInContext(ctx, userID) + ctx = logging.AddMetaToContext(ctx, + slog.String("userId", userID), + slog.String("searchTerm", searchTerm), + slog.String("top", topStr), + ) + ctx = reporting.AddExtrasToContext(ctx, + map[string]string{ + "searchTerm": searchTerm, + "top": topStr, + }, + ) + + if searchTerm == "" { + statusCode := http.StatusBadRequest + logging.FromContext(ctx).InfoContext(ctx, "Missing search term", "statusCode", statusCode, "reason", "missing search term") + http.Error(w, "Missing search term parameter 'q'", statusCode) + return + } + + top := 10 // default + if topStr != "" { + var err error + top, err = strconv.Atoi(topStr) + if err != nil || top < 1 || top > 100 { + statusCode := http.StatusBadRequest + logging.FromContext(ctx).InfoContext(ctx, "Invalid top parameter", "statusCode", statusCode, "reason", "invalid top parameter") + http.Error(w, "Invalid top parameter, must be between 1 and 100", statusCode) + return + } + } + + uuids, err := searchUsername(ctx, searchTerm, top) + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "Error searching username", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + responseData, err := json.Marshal(searchUsernameResponseObject{UUIDs: uuids}) + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "Failed to marshal response", "error", err) + + err = fmt.Errorf("failed to marshal search username response: %w", err) + reporting.Report(ctx, err) + + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err = w.Write(responseData); err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "Failed to write response", "error", err) + reporting.Report(ctx, fmt.Errorf("failed to write search username response: %w", err)) + + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + return middleware(handler) +} diff --git a/internal/ports/search_username_test.go b/internal/ports/search_username_test.go new file mode 100644 index 00000000..aa37ab4f --- /dev/null +++ b/internal/ports/search_username_test.go @@ -0,0 +1,194 @@ +package ports_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/ports" + "github.com/stretchr/testify/require" +) + +func TestMakeSearchUsernameHandler(t *testing.T) { + t.Parallel() + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + noopMiddleware := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h(w, r) + } + } + + makeSearchUsername := func(t *testing.T, expectedSearchTerm string, expectedTop int, uuids []string, err error) (app.SearchUsername, *bool) { + called := false + return func(ctx context.Context, searchTerm string, top int) ([]string, error) { + t.Helper() + require.Equal(t, expectedSearchTerm, searchTerm) + require.Equal(t, expectedTop, top) + + called = true + + return uuids, err + }, &called + } + + makeSearchUsernameHandler := func(searchUsername app.SearchUsername) http.HandlerFunc { + return ports.MakeSearchUsernameHandler( + searchUsername, + testLogger, + noopMiddleware, + ) + } + + makeRequest := func(searchTerm string, top string) *http.Request { + url := "/v1/search/username?q=" + searchTerm + if top != "" { + url += "&top=" + top + } + req := httptest.NewRequest("GET", url, nil) + return req + } + + t.Run("successful search with results", func(t *testing.T) { + t.Parallel() + + uuids := []string{ + "01234567-89ab-cdef-0123-456789abcdef", + "12345678-9abc-def0-1234-56789abcdef0", + } + + searchUsernameFunc, called := makeSearchUsername(t, "testuser", 10, uuids, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("testuser", "") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.JSONEq(t, `{"uuids":["01234567-89ab-cdef-0123-456789abcdef","12345678-9abc-def0-1234-56789abcdef0"]}`, w.Body.String()) + require.True(t, *called) + require.Equal(t, "application/json", w.Result().Header.Get("Content-Type")) + }) + + t.Run("successful search with no results", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "nonexistent", 10, []string{}, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("nonexistent", "") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.JSONEq(t, `{"uuids":[]}`, w.Body.String()) + require.True(t, *called) + require.Equal(t, "application/json", w.Result().Header.Get("Content-Type")) + }) + + t.Run("custom top parameter", func(t *testing.T) { + t.Parallel() + + uuids := []string{"uuid1", "uuid2"} + + searchUsernameFunc, called := makeSearchUsername(t, "user", 5, uuids, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("user", "5") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.JSONEq(t, `{"uuids":["uuid1","uuid2"]}`, w.Body.String()) + require.True(t, *called) + require.Equal(t, "application/json", w.Result().Header.Get("Content-Type")) + }) + + t.Run("missing search term", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "", 10, nil, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := httptest.NewRequest("GET", "/v1/search/username", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "Missing search term") + require.False(t, *called) + }) + + t.Run("invalid top parameter - not a number", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "user", 10, nil, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("user", "invalid") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "Invalid top parameter") + require.False(t, *called) + }) + + t.Run("invalid top parameter - too low", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "user", 10, nil, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("user", "0") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "Invalid top parameter") + require.False(t, *called) + }) + + t.Run("invalid top parameter - too high", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "user", 10, nil, nil) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("user", "101") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "Invalid top parameter") + require.False(t, *called) + }) + + t.Run("internal server error", func(t *testing.T) { + t.Parallel() + + searchUsernameFunc, called := makeSearchUsername(t, "error", 10, nil, fmt.Errorf("database error")) + handler := makeSearchUsernameHandler(searchUsernameFunc) + + req := makeRequest("error", "") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "Internal server error") + require.True(t, *called) + }) +} diff --git a/main.go b/main.go index cc824f85..edc812e8 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,8 @@ func main() { fail("Failed to initialize GetTagsWithCache", "error", err.Error()) } + searchUsername := app.BuildSearchUsername(accountRepo) + getHistory := app.BuildGetHistory(playerRepo, updatePlayerInInterval) getPlayerPITs := app.BuildGetPlayerPITs(playerRepo, updatePlayerInInterval) @@ -210,6 +212,15 @@ func main() { ), ) + handleFunc( + "GET /v1/search/username", + ports.MakeSearchUsernameHandler( + searchUsername, + logger.With("port", "search_username"), + sentryMiddleware, + ), + ) + handleFunc( "OPTIONS /v1/account/username/{username}", ports.BuildCORSHandler(allowedOrigins), From 38c59a86cf833a49dd627cfdd6f89655c9b6b724 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:51:04 +0000 Subject: [PATCH 5/5] Fix code review issues: remove invalid http.Error call and clarify comment Co-authored-by: Amund211 <14028449+Amund211@users.noreply.github.com> --- internal/adapters/accountrepository/postgres.go | 3 ++- internal/ports/search_username.go | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/adapters/accountrepository/postgres.go b/internal/adapters/accountrepository/postgres.go index ca09d943..71160067 100644 --- a/internal/adapters/accountrepository/postgres.go +++ b/internal/adapters/accountrepository/postgres.go @@ -301,7 +301,8 @@ func (p *Postgres) SearchUsername(ctx context.Context, searchTerm string, top in return nil, err } - uuids := []string{} // Initialize to empty slice, not nil + // Initialize to empty slice instead of nil for consistent JSON marshaling ([] vs null) + uuids := []string{} err = txx.SelectContext(ctx, &uuids, ` SELECT player_uuid FROM usernames diff --git a/internal/ports/search_username.go b/internal/ports/search_username.go index d1300c21..b11aca16 100644 --- a/internal/ports/search_username.go +++ b/internal/ports/search_username.go @@ -126,8 +126,7 @@ func MakeSearchUsernameHandler( if _, err = w.Write(responseData); err != nil { logging.FromContext(ctx).ErrorContext(ctx, "Failed to write response", "error", err) reporting.Report(ctx, fmt.Errorf("failed to write search username response: %w", err)) - - http.Error(w, "Internal server error", http.StatusInternalServerError) + // Cannot call http.Error here since headers and status are already sent return } }