From 9be036d31a8af19d70cbd2f6d0850d80c0ead1f4 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 27 May 2026 19:06:00 +0200 Subject: [PATCH 1/5] api/v1: public stats and recent reversals endpoints Three new IP-rate-limited public endpoints, mirroring the existing api/v1/users pattern (no auth, throttled per IP): GET /api/v1/stats/summary GET /api/v1/stats/reversals/daily?days={7|30|60|90|180|365} GET /api/v1/reversals/recent?limit={1..100} The two /stats endpoints share a 60s in-process sync.Map cache and a shared throttle. The /reversals/recent endpoint returns a slim public projection (marketplace_slug, steam_id, reversed_at, created_at). Authenticated /reversals routes are now wrapped in a chi.Group so AuthMiddleware no longer applies to the new /recent path while preserving every other route's behavior. No schema changes; all queries filter deleted_at IS NULL. Aggregates use raw SQL (COUNT DISTINCT + FILTER, date bucketing via to_char on reversed_at) so we don't drag GORM through a non-trivial expression; the list endpoint stays on the GORM path. README adds a postgres superuser note for pgtestdb and a public endpoints table. Co-authored-by: Cursor --- README.md | 36 ++- api/v1/reversals/reversals.go | 49 ++++ api/v1/reversals/reversals_recent_test.go | 240 +++++++++++++++++++ api/v1/reversals/router.go | 32 ++- api/v1/stats/router.go | 17 ++ api/v1/stats/stats.go | 114 +++++++++ api/v1/stats/stats_test.go | 280 ++++++++++++++++++++++ api/v1/v1.go | 6 + domain/dto/stats.go | 12 + domain/repository/public.go | 4 + repository/public/reversal.go | 67 ++++++ repository/public/reversal_test.go | 234 ++++++++++++++++++ 12 files changed, 1074 insertions(+), 17 deletions(-) create mode 100644 api/v1/reversals/reversals_recent_test.go create mode 100644 api/v1/stats/router.go create mode 100644 api/v1/stats/stats.go create mode 100644 api/v1/stats/stats_test.go create mode 100644 domain/dto/stats.go diff --git a/README.md b/README.md index 1926b7f4..4e60dc32 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [reverse.watch](https://reverse.watch) -Community-driven open trade reversal tracking database for Steam. Participating entities can report trade reverals to the open database. +Community-driven open trade reversal tracking database for Steam. Participating entities can report trade reversals to the open database. ## Interested in Participating? @@ -9,17 +9,45 @@ If you're looking to participate by contributing reversal reports (i.e. marketpl ## Running Locally 1. Ensure Go 1.24+ and PostgreSQL are installed. -2. Copy the config template and fill in your local database credentials: +2. Create the two local databases and (for tests) a `postgres` superuser: + ```bash + createdb private + createdb public + psql -d postgres -c "CREATE USER postgres WITH SUPERUSER PASSWORD 'postgres';" + # If the user already exists: + # psql -d postgres -c "ALTER USER postgres WITH SUPERUSER PASSWORD 'postgres';" + ``` + The superuser is required by [`pgtestdb`](https://github.com/peterldowns/pgtestdb), which spins up disposable databases per test. +3. Copy the config template and fill in your local database credentials: ```bash cp config.example.json config.json ``` -3. Run the service: +4. Run the service: ```bash go run main.go ``` The server starts on port `80` by default (configurable via `HTTP_PORT`). +### Running tests + +```bash +go test ./... +``` + +Tests use `pgtestdb` to provision a fresh database per test against the local Postgres. The `postgres/postgres` superuser from step 2 above is required. + +## Public read endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /api/v1/users/{steamId}` | Single Steam-ID lookup (existing) | +| `GET /api/v1/stats/summary` | Three KPI counts in one call | +| `GET /api/v1/stats/reversals/daily?days={7\|30\|60\|90\|180\|365}` | Daily reversal counts, UTC, zero-filled | +| `GET /api/v1/reversals/recent?limit={1..100}` | Latest non-expunged reversals (slim public projection) | + +All four are public, IP-rate-limited, and return JSON. The two `/stats` endpoints have a 60-second in-process cache. + ## Configuration Configuration is loaded from environment variables or a `config.json` file. @@ -47,4 +75,4 @@ API keys are scoped to an entity and carry a permission bitfield. Keys are prefi ## Rate Limiting -Rate limits are enforced in-memory per process. Throttled responses return `429 Too Many Requests` with `X-RateLimit-*` and `Retry-After` headers. \ No newline at end of file +Rate limits are enforced in-memory per process. Throttled responses return `429 Too Many Requests` with `X-RateLimit-*` and `Retry-After` headers. diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index 0b3a294a..d6d2f929 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -236,6 +236,55 @@ func exportReversals(w http.ResponseWriter, r *http.Request) { w.Write(buf.Bytes()) } +type recentReversal struct { + MarketplaceSlug string `json:"marketplace_slug"` + SteamID models.SteamID `json:"steam_id"` + ReversedAt uint64 `json:"reversed_at"` + CreatedAt uint64 `json:"created_at"` +} + +type listRecentResponse struct { + Data []recentReversal `json:"data"` +} + +func listRecentHandler(w http.ResponseWriter, r *http.Request) { + factory, ok := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + if !ok { + render.Errorf(w, r, errors.InternalServerError, "missing factory from context") + return + } + + const maxRecentLimit = 100 + + limit := maxRecentLimit + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + parsed, err := strconv.Atoi(limitStr) + if err != nil || parsed <= 0 || parsed > maxRecentLimit { + render.Errorf(w, r, errors.BadRequest, "limit must be between 1 and %d", maxRecentLimit) + return + } + limit = parsed + } + + reversals, err := factory.Reversal().ListRecent(limit) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to list recent reversals") + return + } + + data := make([]recentReversal, 0, len(reversals)) + for _, rev := range reversals { + data = append(data, recentReversal{ + MarketplaceSlug: rev.MarketplaceSlug, + SteamID: rev.SteamID, + ReversedAt: rev.ReversedAt, + CreatedAt: rev.CreatedAt, + }) + } + + render.JSON(w, r, listRecentResponse{Data: data}) +} + func expungeReversal(w http.ResponseWriter, r *http.Request) { factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) key := r.Context().Value(middleware.KeyContextKey).(*models.Key) diff --git a/api/v1/reversals/reversals_recent_test.go b/api/v1/reversals/reversals_recent_test.go new file mode 100644 index 00000000..494325e6 --- /dev/null +++ b/api/v1/reversals/reversals_recent_test.go @@ -0,0 +1,240 @@ +package reversals + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "reverse-watch/domain/models" + "reverse-watch/domain/models/constants" + "reverse-watch/errors" + "reverse-watch/internal/testutil" + "reverse-watch/middleware" + "reverse-watch/repository/factory" + "reverse-watch/secret" + "reverse-watch/util" + + "gorm.io/gorm" +) + +func buildRecentHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { + t.Helper() + + db := testutil.NewTestDB(t) + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + + handler := http.HandlerFunc(listRecentHandler) + return middleware.FactoryMiddleware(f)(handler), db +} + +func TestListRecentHandler(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + + // 5 rows, monotonically increasing CreatedAt. Row id=3 is expunged. + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(base + 400), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: base + 500}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: base + 600}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var body listRecentResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + + wantSteamIDs := []models.SteamID{ + 76561197960287934, // id=5 + 76561197960287933, // id=4 + 76561197960287931, // id=2 + 76561197960287930, // id=1 + } + if len(body.Data) != len(wantSteamIDs) { + t.Fatalf("len(data) = %d, want %d", len(body.Data), len(wantSteamIDs)) + } + for i, want := range wantSteamIDs { + if body.Data[i].SteamID != want { + t.Errorf("data[%d].SteamID = %d, want %d", i, body.Data[i].SteamID, want) + } + } +} + +func TestListRecentHandler_RespectsLimit(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent?limit=2", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var body listRecentResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Data) != 2 { + t.Errorf("len(data) = %d, want 2", len(body.Data)) + } +} + +func TestListRecentHandler_InvalidLimit(t *testing.T) { + t.Parallel() + + handler, _ := buildRecentHandlerStack(t) + + testCases := []struct { + name string + limit string + }{ + {name: "zero", limit: "0"}, + {name: "negative", limit: "-1"}, + {name: "overMax", limit: "101"}, + {name: "nonNumeric", limit: "abc"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/recent?limit="+tc.limit, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + var body errors.Error + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Details != "limit must be between 1 and 100" { + t.Errorf("details = %q, want %q", body.Details, "limit must be between 1 and 100") + } + }) + } +} + +func TestListRecentHandler_ResponseShape(t *testing.T) { + t.Parallel() + + handler, db := buildRecentHandlerStack(t) + + base := models.Epoch + 1000 + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: base + 50, + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/recent", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + // Decode as raw JSON to assert the exact wire shape (especially steam_id as a string). + var raw struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.NewDecoder(w.Result().Body).Decode(&raw); err != nil { + t.Fatalf("decode: %v", err) + } + if len(raw.Data) != 1 { + t.Fatalf("len(data) = %d, want 1", len(raw.Data)) + } + row := raw.Data[0] + + expectedKeys := []string{"marketplace_slug", "steam_id", "reversed_at", "created_at"} + for _, k := range expectedKeys { + if _, ok := row[k]; !ok { + t.Errorf("missing key %q in response", k) + } + } + for k := range row { + ok := false + for _, want := range expectedKeys { + if k == want { + ok = true + break + } + } + if !ok { + t.Errorf("unexpected key %q in response", k) + } + } + + steamIDValue, ok := row["steam_id"].(string) + if !ok { + t.Errorf("steam_id should be a JSON string, got %T", row["steam_id"]) + } + if steamIDValue != "76561197960287930" { + t.Errorf("steam_id = %q, want %q", steamIDValue, "76561197960287930") + } +} diff --git a/api/v1/reversals/router.go b/api/v1/reversals/router.go index cb710d06..5af57520 100644 --- a/api/v1/reversals/router.go +++ b/api/v1/reversals/router.go @@ -12,23 +12,29 @@ import ( func Router() chi.Router { r := chi.NewRouter() - r.Use(middleware.AuthMiddleware) - r.With( - middleware.RequirePermissions(models.PermissionWrite), - ratelimit.ThrottleByAPIKey(time.Hour, 2_000), - ).Post("/", createReversals) + r.With(ratelimit.ThrottleByIP(time.Minute, 30)).Get("/recent", listRecentHandler) - r.With( - middleware.RequirePermissions(models.PermissionDelete), - ratelimit.ThrottleByAPIKey(time.Hour, 2_000), - ).Delete("/{id}", expungeReversal) + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware) - r.Route("/", func(r chi.Router) { - r.Use(middleware.RequirePermissions(models.PermissionExport)) + r.With( + middleware.RequirePermissions(models.PermissionWrite), + ratelimit.ThrottleByAPIKey(time.Hour, 2_000), + ).Post("/", createReversals) - r.With(ratelimit.ThrottleByAPIKey(time.Minute, 300)).Get("/", listReversalsHandler) - r.With(ratelimit.ThrottleByAPIKey(time.Minute, 60)).Get("/export", exportReversals) + r.With( + middleware.RequirePermissions(models.PermissionDelete), + ratelimit.ThrottleByAPIKey(time.Hour, 2_000), + ).Delete("/{id}", expungeReversal) + + r.Route("/", func(r chi.Router) { + r.Use(middleware.RequirePermissions(models.PermissionExport)) + + r.With(ratelimit.ThrottleByAPIKey(time.Minute, 300)).Get("/", listReversalsHandler) + r.With(ratelimit.ThrottleByAPIKey(time.Minute, 60)).Get("/export", exportReversals) + }) }) + return r } diff --git a/api/v1/stats/router.go b/api/v1/stats/router.go new file mode 100644 index 00000000..ab0b8ad8 --- /dev/null +++ b/api/v1/stats/router.go @@ -0,0 +1,17 @@ +package stats + +import ( + "time" + + "reverse-watch/ratelimit" + + "github.com/go-chi/chi/v5" +) + +func Router() chi.Router { + r := chi.NewRouter() + throttle := ratelimit.ThrottleByIP(time.Minute, 60) + r.With(throttle).Get("/summary", summaryHandler) + r.With(throttle).Get("/reversals/daily", dailyHandler) + return r +} diff --git a/api/v1/stats/stats.go b/api/v1/stats/stats.go new file mode 100644 index 00000000..1d763c60 --- /dev/null +++ b/api/v1/stats/stats.go @@ -0,0 +1,114 @@ +package stats + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "sync" + "time" + + "reverse-watch/domain/dto" + "reverse-watch/domain/repository" + "reverse-watch/errors" + "reverse-watch/middleware" + "reverse-watch/render" +) + +const cacheTTL = 60 * time.Second + +var allowedDays = []int{7, 30, 60, 90, 180, 365} + +type cacheEntry struct { + at time.Time + payload []byte +} + +var cache sync.Map + +func cacheGet(key string) ([]byte, bool) { + v, ok := cache.Load(key) + if !ok { + return nil, false + } + e := v.(cacheEntry) + if time.Since(e.at) > cacheTTL { + return nil, false + } + return e.payload, true +} + +func cacheSet(key string, payload []byte) { + cache.Store(key, cacheEntry{at: time.Now(), payload: payload}) +} + +// writeCachedJSON bypasses render.JSON so we can serve the same marshalled +// bytes on every cache hit without re-encoding. +func writeCachedJSON(w http.ResponseWriter, payload []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) +} + +func summaryHandler(w http.ResponseWriter, r *http.Request) { + const key = "summary" + if payload, ok := cacheGet(key); ok { + writeCachedJSON(w, payload) + return + } + + factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + + stats, err := factory.Reversal().SummaryStats() + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to load summary stats") + return + } + + payload, err := json.Marshal(stats) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to encode summary stats") + return + } + cacheSet(key, payload) + writeCachedJSON(w, payload) +} + +type dailyResponse struct { + Data []dto.DailyCount `json:"data"` +} + +func dailyHandler(w http.ResponseWriter, r *http.Request) { + days := 30 + if daysStr := r.URL.Query().Get("days"); daysStr != "" { + parsed, err := strconv.Atoi(daysStr) + if err != nil || !slices.Contains(allowedDays, parsed) { + render.Errorf(w, r, errors.BadRequest, "days must be one of 7, 30, 60, 90, 180, 365") + return + } + days = parsed + } + + key := fmt.Sprintf("daily:%d", days) + if payload, ok := cacheGet(key); ok { + writeCachedJSON(w, payload) + return + } + + factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) + + counts, err := factory.Reversal().DailyCounts(days) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to load daily counts") + return + } + + payload, err := json.Marshal(dailyResponse{Data: counts}) + if err != nil { + render.Errorf(w, r, errors.InternalServerError, "failed to encode daily counts") + return + } + cacheSet(key, payload) + writeCachedJSON(w, payload) +} diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go new file mode 100644 index 00000000..df9af69b --- /dev/null +++ b/api/v1/stats/stats_test.go @@ -0,0 +1,280 @@ +package stats + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "reverse-watch/domain/dto" + "reverse-watch/domain/models" + "reverse-watch/domain/models/constants" + "reverse-watch/errors" + "reverse-watch/internal/testutil" + "reverse-watch/middleware" + "reverse-watch/repository/factory" + "reverse-watch/secret" + "reverse-watch/util" + + "github.com/google/go-cmp/cmp" + "gorm.io/gorm" +) + +func resetCache() { + cache = sync.Map{} +} + +func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { + t.Helper() + + db := testutil.NewTestDB(t) + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + + router := Router() + finalHandler := middleware.FactoryMiddleware(f)(router) + return finalHandler, db +} + +func TestSummaryHandler(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := uint64(time.Now().UnixMilli()) + hourMs := uint64(60 * 60 * 1000) + withinDay := now - hourMs + olderThanDay := now - 36*hourMs + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now - 24*hourMs), + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/summary", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got dto.SummaryStats + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + want := dto.SummaryStats{ + TradersIndexed: 3, + TradersFlagged: 2, + TradersFlagged24h: 1, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("SummaryStats mismatch (-want +got):\n%s", diff) + } +} + +func TestSummaryHandler_Caches(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := uint64(time.Now().UnixMilli()) + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: now - 60*60*1000}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + ) + + hit := func() dto.SummaryStats { + r := httptest.NewRequest(http.MethodGet, "/summary", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var s dto.SummaryStats + if err := json.NewDecoder(w.Result().Body).Decode(&s); err != nil { + t.Fatalf("decode: %v", err) + } + return s + } + + first := hit() + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: now - 30*60*1000}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + ) + + second := hit() + if second != first { + t.Errorf("expected cached response unchanged: first=%+v second=%+v", first, second) + } +} + +func TestDailyHandler(t *testing.T) { + resetCache() + + handler, db := buildHandlerStack(t) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 1, + }, + &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.AddDate(0, 0, -1).Add(12 * time.Hour).UnixMilli()), + }, + ) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days=30", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != 30 { + t.Fatalf("len(data) = %d, want 30", len(got.Data)) + } + + todayKey := today.Format("2006-01-02") + yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02") + + byDate := make(map[string]uint64, len(got.Data)) + for _, b := range got.Data { + byDate[b.Date] = b.Count + } + if byDate[todayKey] != 1 { + t.Errorf("today bucket = %d, want 1", byDate[todayKey]) + } + if byDate[yesterdayKey] != 1 { + t.Errorf("yesterday bucket = %d, want 1", byDate[yesterdayKey]) + } +} + +func TestDailyHandler_InvalidDays(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + const wantDetails = "days must be one of 7, 30, 60, 90, 180, 365" + testCases := []struct { + name string + days string + }{ + {name: "outOfRange", days: "45"}, + {name: "negative", days: "-1"}, + {name: "nonNumeric", days: "abc"}, + {name: "zero", days: "0"}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+tc.days, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + var body errors.Error + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Details != wantDetails { + t.Errorf("details = %q, want %q", body.Details, wantDetails) + } + }) + } +} + +func TestDailyHandler_AcceptedDays(t *testing.T) { + // Each accepted value should return a fully zero-filled series of + // exactly that many buckets. Empty DB keeps the assertion focused on + // the length contract that the picker depends on. + for _, days := range []int{7, 30, 60, 90, 180, 365} { + days := days + t.Run(strconv.Itoa(days), func(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+strconv.Itoa(days), nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != days { + t.Errorf("len(data) = %d, want %d", len(got.Data), days) + } + }) + } +} + +func TestDailyHandler_DefaultDays(t *testing.T) { + resetCache() + handler, _ := buildHandlerStack(t) + + r := httptest.NewRequest(http.MethodGet, "/reversals/daily", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + var got dailyResponse + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Data) != 30 { + t.Errorf("default len(data) = %d, want 30", len(got.Data)) + } +} diff --git a/api/v1/v1.go b/api/v1/v1.go index e4ffe1b3..a104facf 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -5,6 +5,7 @@ import ( "reverse-watch/api/v1/health" "reverse-watch/api/v1/marketplace" "reverse-watch/api/v1/reversals" + "reverse-watch/api/v1/stats" "reverse-watch/api/v1/users" "github.com/go-chi/chi/v5" @@ -15,6 +16,11 @@ func Router() chi.Router { r.Mount("/health", health.Router()) r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) + // /stats owns aggregate read endpoints (summary, reversals/daily). The + // /stats/reversals/daily path is semantically adjacent to /reversals/* + // but lives here because it's a public, IP-rate-limited read with a + // different cache policy. + r.Mount("/stats", stats.Router()) r.Mount("/users", users.Router()) r.Mount("/admin", admin.Router()) return r diff --git a/domain/dto/stats.go b/domain/dto/stats.go new file mode 100644 index 00000000..b263bdb6 --- /dev/null +++ b/domain/dto/stats.go @@ -0,0 +1,12 @@ +package dto + +type SummaryStats struct { + TradersIndexed uint64 `json:"traders_indexed"` + TradersFlagged uint64 `json:"traders_flagged"` + TradersFlagged24h uint64 `json:"traders_flagged_24h"` +} + +type DailyCount struct { + Date string `json:"date"` + Count uint64 `json:"count"` +} diff --git a/domain/repository/public.go b/domain/repository/public.go index 13520fa8..96645a9b 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -13,4 +13,8 @@ type ReversalRepository interface { Delete(id models.Snowflake) error DeleteAllUserReports(steamId models.SteamID) error List(opts *dto.ReversalListOptions) ([]*models.Reversal, error) + + SummaryStats() (*dto.SummaryStats, error) + DailyCounts(days int) ([]dto.DailyCount, error) + ListRecent(limit int) ([]*models.Reversal, error) } diff --git a/repository/public/reversal.go b/repository/public/reversal.go index cebda95d..272667a7 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -1,6 +1,8 @@ package public import ( + "time" + "reverse-watch/domain/dto" "reverse-watch/domain/models" "reverse-watch/domain/repository" @@ -123,3 +125,68 @@ func (r *reversalRepository) List(opts *dto.ReversalListOptions) ([]*models.Reve } return reversals, nil } + +func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { + cutoffMs := uint64(time.Now().UnixMilli() - 24*60*60*1000) + + var stats dto.SummaryStats + err := r.conn.Raw(` + SELECT + COUNT(DISTINCT steam_id) AS traders_indexed, + COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged, + COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h + FROM reversals + WHERE deleted_at IS NULL + `, cutoffMs).Scan(&stats).Error + if err != nil { + return nil, err + } + return &stats, nil +} + +func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + windowStart := today.AddDate(0, 0, -(days - 1)) + + var rows []dto.DailyCount + err := r.conn.Raw(` + SELECT + to_char(to_timestamp(reversed_at / 1000) AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, + COUNT(*) AS count + FROM reversals + WHERE deleted_at IS NULL + AND expunged_at IS NULL + AND reversed_at >= ? + GROUP BY date + ORDER BY date ASC + `, uint64(windowStart.UnixMilli())).Scan(&rows).Error + if err != nil { + return nil, err + } + + byDate := make(map[string]uint64, len(rows)) + for _, row := range rows { + byDate[row.Date] = row.Count + } + + result := make([]dto.DailyCount, 0, days) + for d := windowStart; !d.After(today); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + result = append(result, dto.DailyCount{Date: key, Count: byDate[key]}) + } + return result, nil +} + +func (r *reversalRepository) ListRecent(limit int) ([]*models.Reversal, error) { + var reversals []*models.Reversal + err := r.conn.Model(&models.Reversal{}). + Where("expunged_at IS NULL"). + Order("created_at DESC"). + Limit(limit). + Find(&reversals).Error + if err != nil { + return nil, err + } + return reversals, nil +} diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 4f5fc6c0..bba5e646 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -812,6 +812,240 @@ func TestReversalRepository_List(t *testing.T) { } } +func TestReversalRepository_SummaryStats(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := uint64(time.Now().UnixMilli()) + hourMs := uint64(60 * 60 * 1000) + withinDay := now - hourMs // -1h: counts as "last 24h" + olderThanDay := now - 36*hourMs // -36h: outside "last 24h" + + // A: 1 non-expunged within 24h + 1 non-expunged older -> indexed, flagged, flagged_24h + // B: 1 expunged within 24h + 1 non-expunged older -> indexed, flagged (not 24h) + // C: 1 expunged older -> indexed only + // D: 1 non-expunged within 24h -> indexed, flagged, flagged_24h + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat-2", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat-2", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: olderThanDay}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(now - 24*hourMs), + }, + &models.Reversal{ + Model: models.Model{ID: 6, CreatedAt: withinDay}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + ) + + got, err := reversalRepo.SummaryStats() + if err != nil { + t.Fatalf("SummaryStats(): %v", err) + } + want := &dto.SummaryStats{ + TradersIndexed: 4, + TradersFlagged: 3, + TradersFlagged24h: 2, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("SummaryStats() mismatch (-want +got):\n%s", diff) + } +} + +func TestReversalRepository_DailyCounts(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + dayMs := func(offset int, addHours int) uint64 { + return uint64(today.AddDate(0, 0, offset).Add(time.Duration(addHours) * time.Hour).UnixMilli()) + } + + // today: 2 non-expunged (very early today, safely past) + // today-1: 1 non-expunged + 1 expunged (excluded) + // today-2: 1 non-expunged + // today-3: 1 non-expunged (outside days=3 window) + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 1, + }, + &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + ReversedAt: uint64(today.UnixMilli()) + 2, + }, + &models.Reversal{ + Model: models.Model{ID: 3}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-1, 12), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: dayMs(-1, 15)}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-1, 15), + ExpungedAt: util.Ptr(dayMs(-1, 16)), + }, + &models.Reversal{ + Model: models.Model{ID: 5}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-2, 5), + }, + &models.Reversal{ + Model: models.Model{ID: 6}, + SteamID: models.SteamID(76561197960287935), + MarketplaceSlug: "csfloat", + ReversedAt: dayMs(-3, 1), + }, + ) + + got, err := reversalRepo.DailyCounts(3) + if err != nil { + t.Fatalf("DailyCounts(3): %v", err) + } + want := []dto.DailyCount{ + {Date: today.AddDate(0, 0, -2).Format("2006-01-02"), Count: 1}, + {Date: today.AddDate(0, 0, -1).Format("2006-01-02"), Count: 1}, + {Date: today.Format("2006-01-02"), Count: 2}, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("DailyCounts(3) mismatch (-want +got):\n%s", diff) + } +} + +func TestReversalRepository_DailyCounts_ZeroFill(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + got, err := reversalRepo.DailyCounts(5) + if err != nil { + t.Fatalf("DailyCounts(5): %v", err) + } + if len(got) != 5 { + t.Fatalf("DailyCounts(5): got %d buckets, want 5", len(got)) + } + for i, b := range got { + wantDate := today.AddDate(0, 0, -(4 - i)).Format("2006-01-02") + if b.Date != wantDate { + t.Errorf("bucket[%d].Date = %q, want %q", i, b.Date, wantDate) + } + if b.Count != 0 { + t.Errorf("bucket[%d].Count = %d, want 0", i, b.Count) + } + } +} + +func TestReversalRepository_ListRecent(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + base := models.Epoch + 1000 + + // 5 rows with strictly increasing CreatedAt. Row id=3 is expunged. + testutil.Insert(t, db, + &models.Reversal{ + Model: models.Model{ID: 1, CreatedAt: base + 100}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 2, CreatedAt: base + 200}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 3, CreatedAt: base + 300}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "csfloat", + ExpungedAt: util.Ptr(base + 400), + }, + &models.Reversal{ + Model: models.Model{ID: 4, CreatedAt: base + 500}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "csfloat", + }, + &models.Reversal{ + Model: models.Model{ID: 5, CreatedAt: base + 600}, + SteamID: models.SteamID(76561197960287934), + MarketplaceSlug: "csfloat", + }, + ) + + testCases := []struct { + name string + limit int + wantIDs []models.Snowflake + }{ + { + name: "newestFirstExcludingExpunged", + limit: 10, + wantIDs: []models.Snowflake{5, 4, 2, 1}, + }, + { + name: "respectsLimit", + limit: 2, + wantIDs: []models.Snowflake{5, 4}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := reversalRepo.ListRecent(tc.limit) + if err != nil { + t.Fatalf("ListRecent(%d): %v", tc.limit, err) + } + if len(got) != len(tc.wantIDs) { + t.Fatalf("ListRecent(%d): got %d rows, want %d", tc.limit, len(got), len(tc.wantIDs)) + } + for i, wantID := range tc.wantIDs { + if got[i].ID != wantID { + t.Errorf("ListRecent(%d)[%d].ID = %d, want %d", tc.limit, i, got[i].ID, wantID) + } + } + }) + } +} + func TestReversalRepository_List_Pagination(t *testing.T) { t.Parallel() From 3b308814d60c57bc0ea047aee1833c5cc5f59cb3 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 13:30:58 +0200 Subject: [PATCH 2/5] api/v1: address stats review feedback - Remove the in-process 60s sync.Map cache and serve summary/daily stats live; aggregates over ~20K rows are trivial, add caching later if perf degrades. - Group the stats routes under a chi.Group with r.Use(ThrottleByIP) instead of attaching the rate-limit middleware per route. - Reimplement /reversals/recent on top of the existing List(opts) (id DESC, limit, exclude expunged) and drop the bespoke ListRecent method, interface entry, and repo-level test. - Add ReversalListOptions.ExcludeExpunged so List can omit expunged rows for the public recent endpoint. - Remove stale routing comments in v1.go. Co-authored-by: Cursor --- api/v1/reversals/reversals.go | 15 +++++-- api/v1/stats/router.go | 8 ++-- api/v1/stats/stats.go | 65 +----------------------------- api/v1/stats/stats_test.go | 53 ------------------------ api/v1/v1.go | 4 -- domain/dto/reversal.go | 1 + domain/repository/public.go | 1 - repository/public/reversal.go | 16 ++------ repository/public/reversal_test.go | 33 ++++++++++----- 9 files changed, 46 insertions(+), 150 deletions(-) diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index d6d2f929..be390231 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -256,17 +256,26 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { const maxRecentLimit = 100 - limit := maxRecentLimit + limit := uint(maxRecentLimit) if limitStr := r.URL.Query().Get("limit"); limitStr != "" { parsed, err := strconv.Atoi(limitStr) if err != nil || parsed <= 0 || parsed > maxRecentLimit { render.Errorf(w, r, errors.BadRequest, "limit must be between 1 and %d", maxRecentLimit) return } - limit = parsed + limit = uint(parsed) } - reversals, err := factory.Reversal().ListRecent(limit) + opts := &dto.ReversalListOptions{ + Limit: &limit, + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + ExcludeExpunged: true, + } + + reversals, err := factory.Reversal().List(opts) if err != nil { render.Errorf(w, r, errors.InternalServerError, "failed to list recent reversals") return diff --git a/api/v1/stats/router.go b/api/v1/stats/router.go index ab0b8ad8..27502826 100644 --- a/api/v1/stats/router.go +++ b/api/v1/stats/router.go @@ -10,8 +10,10 @@ import ( func Router() chi.Router { r := chi.NewRouter() - throttle := ratelimit.ThrottleByIP(time.Minute, 60) - r.With(throttle).Get("/summary", summaryHandler) - r.With(throttle).Get("/reversals/daily", dailyHandler) + r.Group(func(r chi.Router) { + r.Use(ratelimit.ThrottleByIP(time.Minute, 60)) + r.Get("/summary", summaryHandler) + r.Get("/reversals/daily", dailyHandler) + }) return r } diff --git a/api/v1/stats/stats.go b/api/v1/stats/stats.go index 1d763c60..c3307769 100644 --- a/api/v1/stats/stats.go +++ b/api/v1/stats/stats.go @@ -1,13 +1,9 @@ package stats import ( - "encoding/json" - "fmt" "net/http" "slices" "strconv" - "sync" - "time" "reverse-watch/domain/dto" "reverse-watch/domain/repository" @@ -16,48 +12,9 @@ import ( "reverse-watch/render" ) -const cacheTTL = 60 * time.Second - var allowedDays = []int{7, 30, 60, 90, 180, 365} -type cacheEntry struct { - at time.Time - payload []byte -} - -var cache sync.Map - -func cacheGet(key string) ([]byte, bool) { - v, ok := cache.Load(key) - if !ok { - return nil, false - } - e := v.(cacheEntry) - if time.Since(e.at) > cacheTTL { - return nil, false - } - return e.payload, true -} - -func cacheSet(key string, payload []byte) { - cache.Store(key, cacheEntry{at: time.Now(), payload: payload}) -} - -// writeCachedJSON bypasses render.JSON so we can serve the same marshalled -// bytes on every cache hit without re-encoding. -func writeCachedJSON(w http.ResponseWriter, payload []byte) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(payload) -} - func summaryHandler(w http.ResponseWriter, r *http.Request) { - const key = "summary" - if payload, ok := cacheGet(key); ok { - writeCachedJSON(w, payload) - return - } - factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) stats, err := factory.Reversal().SummaryStats() @@ -66,13 +23,7 @@ func summaryHandler(w http.ResponseWriter, r *http.Request) { return } - payload, err := json.Marshal(stats) - if err != nil { - render.Errorf(w, r, errors.InternalServerError, "failed to encode summary stats") - return - } - cacheSet(key, payload) - writeCachedJSON(w, payload) + render.JSON(w, r, stats) } type dailyResponse struct { @@ -90,12 +41,6 @@ func dailyHandler(w http.ResponseWriter, r *http.Request) { days = parsed } - key := fmt.Sprintf("daily:%d", days) - if payload, ok := cacheGet(key); ok { - writeCachedJSON(w, payload) - return - } - factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory) counts, err := factory.Reversal().DailyCounts(days) @@ -104,11 +49,5 @@ func dailyHandler(w http.ResponseWriter, r *http.Request) { return } - payload, err := json.Marshal(dailyResponse{Data: counts}) - if err != nil { - render.Errorf(w, r, errors.InternalServerError, "failed to encode daily counts") - return - } - cacheSet(key, payload) - writeCachedJSON(w, payload) + render.JSON(w, r, dailyResponse{Data: counts}) } diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go index df9af69b..6494ae6b 100644 --- a/api/v1/stats/stats_test.go +++ b/api/v1/stats/stats_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "strconv" - "sync" "testing" "time" @@ -23,10 +22,6 @@ import ( "gorm.io/gorm" ) -func resetCache() { - cache = sync.Map{} -} - func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { t.Helper() @@ -47,8 +42,6 @@ func buildHandlerStack(t *testing.T) (http.Handler, *gorm.DB) { } func TestSummaryHandler(t *testing.T) { - resetCache() - handler, db := buildHandlerStack(t) now := uint64(time.Now().UnixMilli()) @@ -98,50 +91,7 @@ func TestSummaryHandler(t *testing.T) { } } -func TestSummaryHandler_Caches(t *testing.T) { - resetCache() - - handler, db := buildHandlerStack(t) - - now := uint64(time.Now().UnixMilli()) - testutil.Insert(t, db, - &models.Reversal{ - Model: models.Model{ID: 1, CreatedAt: now - 60*60*1000}, - SteamID: models.SteamID(76561197960287930), - MarketplaceSlug: "csfloat", - }, - ) - - hit := func() dto.SummaryStats { - r := httptest.NewRequest(http.MethodGet, "/summary", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - var s dto.SummaryStats - if err := json.NewDecoder(w.Result().Body).Decode(&s); err != nil { - t.Fatalf("decode: %v", err) - } - return s - } - - first := hit() - - testutil.Insert(t, db, - &models.Reversal{ - Model: models.Model{ID: 2, CreatedAt: now - 30*60*1000}, - SteamID: models.SteamID(76561197960287931), - MarketplaceSlug: "csfloat", - }, - ) - - second := hit() - if second != first { - t.Errorf("expected cached response unchanged: first=%+v second=%+v", first, second) - } -} - func TestDailyHandler(t *testing.T) { - resetCache() - handler, db := buildHandlerStack(t) now := time.Now().UTC() @@ -195,7 +145,6 @@ func TestDailyHandler(t *testing.T) { } func TestDailyHandler_InvalidDays(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) const wantDetails = "days must be one of 7, 30, 60, 90, 180, 365" @@ -236,7 +185,6 @@ func TestDailyHandler_AcceptedDays(t *testing.T) { for _, days := range []int{7, 30, 60, 90, 180, 365} { days := days t.Run(strconv.Itoa(days), func(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) r := httptest.NewRequest(http.MethodGet, "/reversals/daily?days="+strconv.Itoa(days), nil) @@ -259,7 +207,6 @@ func TestDailyHandler_AcceptedDays(t *testing.T) { } func TestDailyHandler_DefaultDays(t *testing.T) { - resetCache() handler, _ := buildHandlerStack(t) r := httptest.NewRequest(http.MethodGet, "/reversals/daily", nil) diff --git a/api/v1/v1.go b/api/v1/v1.go index a104facf..e6cee6f9 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -16,10 +16,6 @@ func Router() chi.Router { r.Mount("/health", health.Router()) r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) - // /stats owns aggregate read endpoints (summary, reversals/daily). The - // /stats/reversals/daily path is semantically adjacent to /reversals/* - // but lives here because it's a public, IP-rate-limited read with a - // different cache policy. r.Mount("/stats", stats.Router()) r.Mount("/users", users.Router()) r.Mount("/admin", admin.Router()) diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index 28db6941..1486ee22 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -13,6 +13,7 @@ type ReversalListOptions struct { Cursor *Cursor Limit *uint OrderParam *OrderParam + ExcludeExpunged bool } type ReversalUpdates struct { diff --git a/domain/repository/public.go b/domain/repository/public.go index 96645a9b..e363781b 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -16,5 +16,4 @@ type ReversalRepository interface { SummaryStats() (*dto.SummaryStats, error) DailyCounts(days int) ([]dto.DailyCount, error) - ListRecent(limit int) ([]*models.Reversal, error) } diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 272667a7..cf4ae5a5 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -103,6 +103,9 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm if opts.MarketplaceSlug != nil && *opts.MarketplaceSlug != "" { query = query.Where("marketplace_slug = ?", opts.MarketplaceSlug) } + if opts.ExcludeExpunged { + query = query.Where("expunged_at IS NULL") + } if opts.Cursor != nil { // Adjust direction based on order specified if desc { @@ -177,16 +180,3 @@ func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { } return result, nil } - -func (r *reversalRepository) ListRecent(limit int) ([]*models.Reversal, error) { - var reversals []*models.Reversal - err := r.conn.Model(&models.Reversal{}). - Where("expunged_at IS NULL"). - Order("created_at DESC"). - Limit(limit). - Find(&reversals).Error - if err != nil { - return nil, err - } - return reversals, nil -} diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index bba5e646..02fa8d54 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -973,7 +973,7 @@ func TestReversalRepository_DailyCounts_ZeroFill(t *testing.T) { } } -func TestReversalRepository_ListRecent(t *testing.T) { +func TestReversalRepository_List_ExcludeExpunged(t *testing.T) { t.Parallel() db := testutil.NewTestDB(t) @@ -1013,33 +1013,46 @@ func TestReversalRepository_ListRecent(t *testing.T) { testCases := []struct { name string - limit int + opts *dto.ReversalListOptions wantIDs []models.Snowflake }{ { - name: "newestFirstExcludingExpunged", - limit: 10, + name: "newestFirstExcludingExpunged", + opts: &dto.ReversalListOptions{ + ExcludeExpunged: true, + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }, wantIDs: []models.Snowflake{5, 4, 2, 1}, }, { - name: "respectsLimit", - limit: 2, + name: "respectsLimit", + opts: &dto.ReversalListOptions{ + ExcludeExpunged: true, + Limit: util.Ptr[uint](2), + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }, wantIDs: []models.Snowflake{5, 4}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - got, err := reversalRepo.ListRecent(tc.limit) + got, err := reversalRepo.List(tc.opts) if err != nil { - t.Fatalf("ListRecent(%d): %v", tc.limit, err) + t.Fatalf("List(): %v", err) } if len(got) != len(tc.wantIDs) { - t.Fatalf("ListRecent(%d): got %d rows, want %d", tc.limit, len(got), len(tc.wantIDs)) + t.Fatalf("List(): got %d rows, want %d", len(got), len(tc.wantIDs)) } for i, wantID := range tc.wantIDs { if got[i].ID != wantID { - t.Errorf("ListRecent(%d)[%d].ID = %d, want %d", tc.limit, i, got[i].ID, wantID) + t.Errorf("List()[%d].ID = %d, want %d", i, got[i].ID, wantID) } } }) From 8440a0defb0898fd2709c4478f7d73e663d4d50d Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 14:07:38 +0200 Subject: [PATCH 3/5] repository: track Steam ID searches for stats summary Add a dedicated search_counts table (steam_id PK, count, last_searched_at) in the public database so the public /stats/summary endpoint can report a real "Steam IDs Searched" KPI instead of deriving it from reversal counts. The public user-status lookup now upserts/increments the per-Steam-ID count on each search; counting errors are logged and swallowed so analytics never breaks the user-facing lookup. SummaryStats reads COUNT(*) (distinct Steam IDs searched) and SUM(count) (total searches) from the new table. The summary response field traders_indexed is replaced by steam_ids_searched and total_searches. Co-authored-by: Cursor --- api/v1/stats/stats_test.go | 9 ++- api/v1/users/users.go | 8 +++ api/v1/users/users_test.go | 91 +++++++++++++++++++++++++++ domain/dto/stats.go | 7 ++- domain/models/searchcount.go | 14 +++++ domain/repository/factory.go | 2 + domain/repository/public.go | 6 ++ internal/testutil/db.go | 1 + repository/factory/factory.go | 6 ++ repository/factory/transaction.go | 14 +++-- repository/public/public.go | 1 + repository/public/reversal.go | 12 +++- repository/public/reversal_test.go | 18 +++++- repository/public/searchcount.go | 33 ++++++++++ repository/public/searchcount_test.go | 69 ++++++++++++++++++++ 15 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 domain/models/searchcount.go create mode 100644 repository/public/searchcount.go create mode 100644 repository/public/searchcount_test.go diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go index 6494ae6b..ca187a62 100644 --- a/api/v1/stats/stats_test.go +++ b/api/v1/stats/stats_test.go @@ -68,6 +68,12 @@ func TestSummaryHandler(t *testing.T) { }, ) + // 2 distinct Steam IDs searched a total of 3 times. + testutil.Insert(t, db, + &models.SearchCount{SteamID: models.SteamID(76561197960287940), Count: 2, LastSearchedAt: now}, + &models.SearchCount{SteamID: models.SteamID(76561197960287941), Count: 1, LastSearchedAt: now}, + ) + r := httptest.NewRequest(http.MethodGet, "/summary", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, r) @@ -82,7 +88,8 @@ func TestSummaryHandler(t *testing.T) { t.Fatalf("decode: %v", err) } want := dto.SummaryStats{ - TradersIndexed: 3, + SteamIDsSearched: 2, + TotalSearches: 3, TradersFlagged: 2, TradersFlagged24h: 1, } diff --git a/api/v1/users/users.go b/api/v1/users/users.go index 7ae68843..a6a79fdf 100644 --- a/api/v1/users/users.go +++ b/api/v1/users/users.go @@ -7,6 +7,7 @@ import ( "reverse-watch/domain/models" "reverse-watch/domain/repository" "reverse-watch/errors" + "reverse-watch/logging" "reverse-watch/middleware" "reverse-watch/render" @@ -38,6 +39,13 @@ func fetchUserStatus(w http.ResponseWriter, r *http.Request) { return } + // Record the lookup for the "Steam IDs Searched" KPI. This is analytics + // only, so a failure here must never fail the user-facing lookup: log and + // continue. + if err := factory.SearchCount().Increment(*steamId); err != nil { + logging.Log.Errorf("failed to increment search count for steam id %q: %v", steamId, err) + } + data := &fetchUserStatusResponse{ SteamID: *steamId, } diff --git a/api/v1/users/users_test.go b/api/v1/users/users_test.go index 3ef8efce..581a6217 100644 --- a/api/v1/users/users_test.go +++ b/api/v1/users/users_test.go @@ -11,6 +11,7 @@ import ( "reverse-watch/domain/models/constants" "reverse-watch/errors" "reverse-watch/internal/testutil" + "reverse-watch/logging" "reverse-watch/middleware" "reverse-watch/repository/factory" "reverse-watch/secret" @@ -457,3 +458,93 @@ func TestFetchUserStatus(t *testing.T) { }) } } + +func newUserStatusHandler(t *testing.T, db *gorm.DB) http.Handler { + t.Helper() + + keygen := secret.NewKeyGenerator(constants.EnvironmentDevelopment) + f, err := factory.NewFactoryWithConfig(&factory.Config{ + PrivateDB: db, + PublicDB: db, + KeyGen: keygen, + }) + if err != nil { + t.Fatalf("NewFactoryWithConfig(): %v", err) + } + return middleware.FactoryMiddleware(f)(http.HandlerFunc(fetchUserStatus)) +} + +func userStatusRequest(steamID models.SteamID) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/"+steamID.String(), nil) + chiContext := chi.NewRouteContext() + chiContext.URLParams.Add("steamId", steamID.String()) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiContext)) +} + +func TestFetchUserStatus_IncrementsSearchCount(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + handler := newUserStatusHandler(t, db) + steamID := models.SteamID(76561197960287930) + + do := func() { + w := httptest.NewRecorder() + handler.ServeHTTP(w, userStatusRequest(steamID)) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Result().StatusCode, http.StatusOK) + } + } + + do() + var sc models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&sc).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if sc.Count != 1 { + t.Errorf("Count = %d, want 1", sc.Count) + } + if sc.LastSearchedAt == 0 { + t.Errorf("LastSearchedAt = 0, want non-zero") + } + + do() + if err := db.Where("steam_id = ?", uint64(steamID)).First(&sc).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if sc.Count != 2 { + t.Errorf("Count = %d, want 2", sc.Count) + } +} + +func TestFetchUserStatus_IncrementFailureDoesNotFailLookup(t *testing.T) { + // A counting failure must never break the user-facing lookup. Drop the + // search_counts table so the increment errors, then assert the lookup still + // succeeds. logging.Initialize() is required because the handler logs the + // swallowed error. + logging.Initialize() + + db := testutil.NewTestDB(t) + handler := newUserStatusHandler(t, db) + + if err := db.Migrator().DropTable(&models.SearchCount{}); err != nil { + t.Fatalf("DropTable(): %v", err) + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, userStatusRequest(models.SteamID(76561197960287930))) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d (lookup must succeed despite count failure)", resp.StatusCode, http.StatusOK) + } + + defer resp.Body.Close() + var respData fetchUserStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if respData.HasReversed { + t.Errorf("HasReversed = true, want false") + } +} diff --git a/domain/dto/stats.go b/domain/dto/stats.go index b263bdb6..c0cc0522 100644 --- a/domain/dto/stats.go +++ b/domain/dto/stats.go @@ -1,7 +1,12 @@ package dto type SummaryStats struct { - TradersIndexed uint64 `json:"traders_indexed"` + // SteamIDsSearched is the number of distinct Steam IDs ever looked up via + // the public user-status endpoint (COUNT(*) of the search_counts table). + SteamIDsSearched uint64 `json:"steam_ids_searched"` + // TotalSearches is the total number of lookups across all Steam IDs + // (SUM(count) of the search_counts table). + TotalSearches uint64 `json:"total_searches"` TradersFlagged uint64 `json:"traders_flagged"` TradersFlagged24h uint64 `json:"traders_flagged_24h"` } diff --git a/domain/models/searchcount.go b/domain/models/searchcount.go new file mode 100644 index 00000000..ebf8254f --- /dev/null +++ b/domain/models/searchcount.go @@ -0,0 +1,14 @@ +package models + +// SearchCount tracks how many times a Steam ID has been looked up via the +// public user-status endpoint. It lives in the public database so the public +// summary stats query can read it directly. The row is upserted on every +// lookup, so the number of rows equals the number of distinct Steam IDs ever +// searched and the sum of Count equals the total number of searches. +type SearchCount struct { + SteamID SteamID `gorm:"primaryKey;not null;autoIncrement:false" json:"steam_id"` + Count uint64 `gorm:"not null;default:0" json:"count"` + // LastSearchedAt is the timestamp of the most recent lookup in + // milliseconds since the Unix epoch. + LastSearchedAt uint64 `gorm:"autoUpdateTime:milli" json:"last_searched_at"` +} diff --git a/domain/repository/factory.go b/domain/repository/factory.go index 5b75d7fd..07d4809f 100644 --- a/domain/repository/factory.go +++ b/domain/repository/factory.go @@ -18,6 +18,7 @@ type PublicTransaction interface { gorm.TxCommitter Reversal() ReversalRepository + SearchCount() SearchCountRepository } type Factory interface { @@ -27,6 +28,7 @@ type Factory interface { Marketplace() MarketplaceRepository AdminAudit() AdminAuditRepository Reversal() ReversalRepository + SearchCount() SearchCountRepository NewPrivateTransaction() PrivateTransaction RunInTransactionPrivate(fn func(PrivateTransaction) error) error diff --git a/domain/repository/public.go b/domain/repository/public.go index e363781b..4450016c 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -17,3 +17,9 @@ type ReversalRepository interface { SummaryStats() (*dto.SummaryStats, error) DailyCounts(days int) ([]dto.DailyCount, error) } + +type SearchCountRepository interface { + // Increment records a single lookup of the given Steam ID, inserting a new + // row or atomically incrementing the existing count. + Increment(steamID models.SteamID) error +} diff --git a/internal/testutil/db.go b/internal/testutil/db.go index b8db631c..85669e3e 100644 --- a/internal/testutil/db.go +++ b/internal/testutil/db.go @@ -27,6 +27,7 @@ func NewTestDB(t *testing.T) *gorm.DB { (*models.Key)(nil), (*models.AdminAudit)(nil), (*models.Reversal)(nil), + (*models.SearchCount)(nil), } for _, model := range mods { diff --git a/repository/factory/factory.go b/repository/factory/factory.go index c8b900ed..a23ae40a 100644 --- a/repository/factory/factory.go +++ b/repository/factory/factory.go @@ -28,6 +28,7 @@ type factory struct { marketplace repository.MarketplaceRepository adminAudit repository.AdminAuditRepository reversal repository.ReversalRepository + searchCount repository.SearchCountRepository } type Config struct { @@ -58,6 +59,7 @@ func NewFactoryWithConfig(cfg *Config) (repository.Factory, error) { marketplace: private.NewMarketplaceRepository(cfg.PrivateDB), adminAudit: private.NewAdminAuditRepository(cfg.PrivateDB), reversal: public.NewReversalRepository(cfg.PublicDB), + searchCount: public.NewSearchCountRepository(cfg.PublicDB), }, nil } @@ -174,6 +176,10 @@ func (f *factory) Reversal() repository.ReversalRepository { return f.reversal } +func (f *factory) SearchCount() repository.SearchCountRepository { + return f.searchCount +} + func (f *factory) Close() error { var errs []error if err := closeDB(f.private); err != nil { diff --git a/repository/factory/transaction.go b/repository/factory/transaction.go index f4ad9d4e..3fa60f90 100644 --- a/repository/factory/transaction.go +++ b/repository/factory/transaction.go @@ -46,14 +46,16 @@ func (t *privateTransaction) AdminAudit() repository.AdminAuditRepository { } type publicTransaction struct { - tx *gorm.DB - reversal repository.ReversalRepository + tx *gorm.DB + reversal repository.ReversalRepository + searchCount repository.SearchCountRepository } func newPublicTransaction(tx *gorm.DB) *publicTransaction { return &publicTransaction{ - tx: tx, - reversal: public.NewReversalRepository(tx), + tx: tx, + reversal: public.NewReversalRepository(tx), + searchCount: public.NewSearchCountRepository(tx), } } @@ -68,3 +70,7 @@ func (t *publicTransaction) Rollback() error { func (t *publicTransaction) Reversal() repository.ReversalRepository { return t.reversal } + +func (t *publicTransaction) SearchCount() repository.SearchCountRepository { + return t.searchCount +} diff --git a/repository/public/public.go b/repository/public/public.go index e0f39346..cb9e60a3 100644 --- a/repository/public/public.go +++ b/repository/public/public.go @@ -9,6 +9,7 @@ import ( func MigrateModels(tx *gorm.DB) error { publicModels := []interface{}{ (*models.Reversal)(nil), + (*models.SearchCount)(nil), } for _, model := range publicModels { diff --git a/repository/public/reversal.go b/repository/public/reversal.go index cf4ae5a5..457fed84 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -135,7 +135,6 @@ func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { var stats dto.SummaryStats err := r.conn.Raw(` SELECT - COUNT(DISTINCT steam_id) AS traders_indexed, COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL) AS traders_flagged, COUNT(DISTINCT steam_id) FILTER (WHERE expunged_at IS NULL AND created_at >= ?) AS traders_flagged24h FROM reversals @@ -144,6 +143,17 @@ func (r *reversalRepository) SummaryStats() (*dto.SummaryStats, error) { if err != nil { return nil, err } + + // "Steam IDs Searched" comes from the dedicated search_counts table: the + // number of rows is the count of distinct Steam IDs ever searched, and the + // sum of count is the total number of searches. Read positionally to avoid + // depending on column-name mapping for the aggregate aliases. + if err := r.conn.Raw(` + SELECT COUNT(*), COALESCE(SUM(count), 0) + FROM search_counts + `).Row().Scan(&stats.SteamIDsSearched, &stats.TotalSearches); err != nil { + return nil, err + } return &stats, nil } diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 02fa8d54..b0b5b7c4 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -862,12 +862,28 @@ func TestReversalRepository_SummaryStats(t *testing.T) { }, ) + // "Steam IDs Searched" is sourced from the search_counts table and is + // independent of the reversal data above: 2 distinct Steam IDs searched a + // total of 5 times. + searchRepo := NewSearchCountRepository(db) + for i := 0; i < 3; i++ { + if err := searchRepo.Increment(models.SteamID(76561197960287940)); err != nil { + t.Fatalf("Increment(): %v", err) + } + } + for i := 0; i < 2; i++ { + if err := searchRepo.Increment(models.SteamID(76561197960287941)); err != nil { + t.Fatalf("Increment(): %v", err) + } + } + got, err := reversalRepo.SummaryStats() if err != nil { t.Fatalf("SummaryStats(): %v", err) } want := &dto.SummaryStats{ - TradersIndexed: 4, + SteamIDsSearched: 2, + TotalSearches: 5, TradersFlagged: 3, TradersFlagged24h: 2, } diff --git a/repository/public/searchcount.go b/repository/public/searchcount.go new file mode 100644 index 00000000..df667055 --- /dev/null +++ b/repository/public/searchcount.go @@ -0,0 +1,33 @@ +package public + +import ( + "time" + + "reverse-watch/domain/models" + "reverse-watch/domain/repository" + + "gorm.io/gorm" +) + +type searchCountRepository struct { + conn *gorm.DB +} + +var _ repository.SearchCountRepository = (*searchCountRepository)(nil) + +func NewSearchCountRepository(conn *gorm.DB) repository.SearchCountRepository { + return &searchCountRepository{ + conn: conn, + } +} + +func (r *searchCountRepository) Increment(steamID models.SteamID) error { + now := uint64(time.Now().UnixMilli()) + return r.conn.Exec(` + INSERT INTO search_counts (steam_id, count, last_searched_at) + VALUES (?, 1, ?) + ON CONFLICT (steam_id) DO UPDATE + SET count = search_counts.count + 1, + last_searched_at = EXCLUDED.last_searched_at + `, uint64(steamID), now).Error +} diff --git a/repository/public/searchcount_test.go b/repository/public/searchcount_test.go new file mode 100644 index 00000000..447c7136 --- /dev/null +++ b/repository/public/searchcount_test.go @@ -0,0 +1,69 @@ +package public + +import ( + "testing" + + "reverse-watch/domain/models" + "reverse-watch/internal/testutil" +) + +func TestSearchCountRepository_Increment(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + repo := NewSearchCountRepository(db) + + steamID := models.SteamID(76561197960287930) + + // First lookup inserts a fresh row with count 1. + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var first models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&first).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if first.SteamID != steamID { + t.Errorf("SteamID = %d, want %d", first.SteamID, steamID) + } + if first.Count != 1 { + t.Errorf("Count = %d, want 1", first.Count) + } + if first.LastSearchedAt == 0 { + t.Errorf("LastSearchedAt = 0, want non-zero") + } + + // Subsequent lookups upsert and increment the same row. + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + if err := repo.Increment(steamID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var second models.SearchCount + if err := db.Where("steam_id = ?", uint64(steamID)).First(&second).Error; err != nil { + t.Fatalf("First(): %v", err) + } + if second.Count != 3 { + t.Errorf("Count = %d, want 3", second.Count) + } + if second.LastSearchedAt < first.LastSearchedAt { + t.Errorf("LastSearchedAt = %d, want >= %d", second.LastSearchedAt, first.LastSearchedAt) + } + + // Distinct Steam IDs get their own rows. + otherID := models.SteamID(76561197960287931) + if err := repo.Increment(otherID); err != nil { + t.Fatalf("Increment(): %v", err) + } + + var rows int64 + if err := db.Model(&models.SearchCount{}).Count(&rows).Error; err != nil { + t.Fatalf("Count(): %v", err) + } + if rows != 2 { + t.Errorf("rows = %d, want 2", rows) + } +} From 8652b503898081969507708ba386a66bfae4b927 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 15:07:44 +0200 Subject: [PATCH 4/5] Harden DailyCounts GROUP BY to positional form Use positional GROUP BY 1 / ORDER BY 1 instead of referencing the SELECT output alias `date`. PostgreSQL supports grouping by output aliases as a documented extension, but the positional form is unambiguous and avoids any reliance on that extension across versions. Results are identical. Co-authored-by: Cursor --- repository/public/reversal.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 457fed84..808cf6f9 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -171,8 +171,8 @@ func (r *reversalRepository) DailyCounts(days int) ([]dto.DailyCount, error) { WHERE deleted_at IS NULL AND expunged_at IS NULL AND reversed_at >= ? - GROUP BY date - ORDER BY date ASC + GROUP BY 1 + ORDER BY 1 ASC `, uint64(windowStart.UnixMilli())).Scan(&rows).Error if err != nil { return nil, err From a1d8b76da0436307f296a2960031b88a56a99066 Mon Sep 17 00:00:00 2001 From: zukwiz Date: Wed, 24 Jun 2026 15:37:36 +0200 Subject: [PATCH 5/5] Order recent reversals feed by reversed_at DESC, id DESC The public /api/v1/reversals/recent feed ordered by snowflake id DESC, but each row's reversed_at reflects when the reversal actually occurred (often from upstream ingest). Backfilled or late-ingested rows can have a high id but an older reversed_at, making the feed order disagree with the daily stats (which bucket on reversed_at) and "latest reversals" semantics. Order the feed by reversed_at DESC with id DESC as a deterministic tiebreaker for stable ordering. Add SecondaryOrderParam to ReversalListOptions and apply it in buildListQuery. Co-authored-by: Cursor --- api/v1/reversals/reversals.go | 8 ++++ api/v1/reversals/reversals_recent_test.go | 20 ++++++-- domain/dto/reversal.go | 5 +- repository/public/reversal.go | 8 ++++ repository/public/reversal_test.go | 57 +++++++++++++++++++++++ 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/api/v1/reversals/reversals.go b/api/v1/reversals/reversals.go index be390231..e4bf414e 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -268,7 +268,15 @@ func listRecentHandler(w http.ResponseWriter, r *http.Request) { opts := &dto.ReversalListOptions{ Limit: &limit, + // Order by when the reversal actually occurred so the feed agrees with + // the daily stats (which bucket on reversed_at) and "latest reversals" + // semantics. id DESC is a deterministic tiebreaker for stable ordering + // (e.g. backfilled rows with a high id but older reversed_at). OrderParam: &dto.OrderParam{ + Column: "reversed_at", + Direction: dto.DESC, + }, + SecondaryOrderParam: &dto.OrderParam{ Column: "id", Direction: dto.DESC, }, diff --git a/api/v1/reversals/reversals_recent_test.go b/api/v1/reversals/reversals_recent_test.go index 494325e6..bf670d5d 100644 --- a/api/v1/reversals/reversals_recent_test.go +++ b/api/v1/reversals/reversals_recent_test.go @@ -43,33 +43,43 @@ func TestListRecentHandler(t *testing.T) { base := models.Epoch + 1000 - // 5 rows, monotonically increasing CreatedAt. Row id=3 is expunged. + // 5 rows. The feed orders by reversed_at DESC, id DESC. CreatedAt is set + // in ascending id order (i.e. ingest order) to prove the feed does NOT use + // id/ingest order. Row id=3 is expunged and must be excluded. Row id=5 is a + // backfill case: it has the highest id but the oldest reversed_at, so it + // must sort last rather than first. Rows id=1 and id=4 share a reversed_at + // to exercise the id DESC tiebreaker (id=4 must come before id=1). testutil.Insert(t, db, &models.Reversal{ Model: models.Model{ID: 1, CreatedAt: base + 100}, SteamID: models.SteamID(76561197960287930), MarketplaceSlug: "csfloat", + ReversedAt: base + 100, }, &models.Reversal{ Model: models.Model{ID: 2, CreatedAt: base + 200}, SteamID: models.SteamID(76561197960287931), MarketplaceSlug: "csfloat", + ReversedAt: base + 500, }, &models.Reversal{ Model: models.Model{ID: 3, CreatedAt: base + 300}, SteamID: models.SteamID(76561197960287932), MarketplaceSlug: "csfloat", + ReversedAt: base + 900, ExpungedAt: util.Ptr(base + 400), }, &models.Reversal{ Model: models.Model{ID: 4, CreatedAt: base + 500}, SteamID: models.SteamID(76561197960287933), MarketplaceSlug: "csfloat", + ReversedAt: base + 100, }, &models.Reversal{ Model: models.Model{ID: 5, CreatedAt: base + 600}, SteamID: models.SteamID(76561197960287934), MarketplaceSlug: "csfloat", + ReversedAt: base + 50, }, ) @@ -88,10 +98,10 @@ func TestListRecentHandler(t *testing.T) { } wantSteamIDs := []models.SteamID{ - 76561197960287934, // id=5 - 76561197960287933, // id=4 - 76561197960287931, // id=2 - 76561197960287930, // id=1 + 76561197960287931, // id=2, reversed_at=base+500 + 76561197960287933, // id=4, reversed_at=base+100 (id tiebreaker over id=1) + 76561197960287930, // id=1, reversed_at=base+100 + 76561197960287934, // id=5, reversed_at=base+50 (backfill: high id, oldest) } if len(body.Data) != len(wantSteamIDs) { t.Fatalf("len(data) = %d, want %d", len(body.Data), len(wantSteamIDs)) diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index 1486ee22..e8c7362f 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -13,7 +13,10 @@ type ReversalListOptions struct { Cursor *Cursor Limit *uint OrderParam *OrderParam - ExcludeExpunged bool + // SecondaryOrderParam is an optional tiebreaker applied after OrderParam, + // producing deterministic, stable ordering when the primary column has ties. + SecondaryOrderParam *OrderParam + ExcludeExpunged bool } type ReversalUpdates struct { diff --git a/repository/public/reversal.go b/repository/public/reversal.go index 808cf6f9..63f28b8c 100644 --- a/repository/public/reversal.go +++ b/repository/public/reversal.go @@ -96,6 +96,14 @@ func (r *reversalRepository) buildListQuery(opts *dto.ReversalListOptions) *gorm query = query.Order(orderBy) } + if opts.SecondaryOrderParam != nil { + tiebreaker := clause.OrderByColumn{ + Column: clause.Column{Name: opts.SecondaryOrderParam.Column}, + Desc: opts.SecondaryOrderParam.Direction == dto.DESC, + } + + query = query.Order(tiebreaker) + } if opts.SteamID.IsValid() { query = query.Where("steam_id = ?", opts.SteamID) diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index b0b5b7c4..14b77994 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -812,6 +812,63 @@ func TestReversalRepository_List(t *testing.T) { } } +func TestReversalRepository_List_SecondaryOrder(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + reversalRepo := NewReversalRepository(db) + + base := models.Epoch + 1000 + + // id=3 is a backfill row: highest id but the oldest reversed_at, so it must + // sort last under reversed_at DESC. id=1 and id=2 share a reversed_at to + // exercise the id DESC tiebreaker (id=2 must come before id=1). + id1 := &models.Reversal{ + Model: models.Model{ID: 1}, + SteamID: models.SteamID(76561197960287930), + MarketplaceSlug: "test-slug", + ReversedAt: base + 100, + } + id2 := &models.Reversal{ + Model: models.Model{ID: 2}, + SteamID: models.SteamID(76561197960287931), + MarketplaceSlug: "test-slug", + ReversedAt: base + 100, + } + id3 := &models.Reversal{ + Model: models.Model{ID: 3}, + SteamID: models.SteamID(76561197960287932), + MarketplaceSlug: "test-slug", + ReversedAt: base + 50, + } + id4 := &models.Reversal{ + Model: models.Model{ID: 4}, + SteamID: models.SteamID(76561197960287933), + MarketplaceSlug: "test-slug", + ReversedAt: base + 500, + } + testutil.Insert(t, db, id1, id2, id3, id4) + + got, err := reversalRepo.List(&dto.ReversalListOptions{ + OrderParam: &dto.OrderParam{ + Column: "reversed_at", + Direction: dto.DESC, + }, + SecondaryOrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }) + if err != nil { + t.Fatalf("List(): %v", err) + } + + want := []*models.Reversal{id4, id2, id1, id3} + if diff := cmp.Diff(got, want, cmpopts.IgnoreFields(models.Reversal{}, "CreatedAt", "UpdatedAt", "ReversedAt")); diff != "" { + t.Error(diff) + } +} + func TestReversalRepository_SummaryStats(t *testing.T) { t.Parallel()