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..e4bf414e 100644 --- a/api/v1/reversals/reversals.go +++ b/api/v1/reversals/reversals.go @@ -236,6 +236,72 @@ 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 := 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 = uint(parsed) + } + + 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, + }, + ExcludeExpunged: true, + } + + reversals, err := factory.Reversal().List(opts) + 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..bf670d5d --- /dev/null +++ b/api/v1/reversals/reversals_recent_test.go @@ -0,0 +1,250 @@ +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. 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, + }, + ) + + 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{ + 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)) + } + 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..27502826 --- /dev/null +++ b/api/v1/stats/router.go @@ -0,0 +1,19 @@ +package stats + +import ( + "time" + + "reverse-watch/ratelimit" + + "github.com/go-chi/chi/v5" +) + +func Router() chi.Router { + r := chi.NewRouter() + 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 new file mode 100644 index 00000000..c3307769 --- /dev/null +++ b/api/v1/stats/stats.go @@ -0,0 +1,53 @@ +package stats + +import ( + "net/http" + "slices" + "strconv" + + "reverse-watch/domain/dto" + "reverse-watch/domain/repository" + "reverse-watch/errors" + "reverse-watch/middleware" + "reverse-watch/render" +) + +var allowedDays = []int{7, 30, 60, 90, 180, 365} + +func summaryHandler(w http.ResponseWriter, r *http.Request) { + 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 + } + + render.JSON(w, r, stats) +} + +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 + } + + 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 + } + + render.JSON(w, r, dailyResponse{Data: counts}) +} diff --git a/api/v1/stats/stats_test.go b/api/v1/stats/stats_test.go new file mode 100644 index 00000000..ca187a62 --- /dev/null +++ b/api/v1/stats/stats_test.go @@ -0,0 +1,234 @@ +package stats + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "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 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) { + 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), + }, + ) + + // 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) + + 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{ + SteamIDsSearched: 2, + TotalSearches: 3, + TradersFlagged: 2, + TradersFlagged24h: 1, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("SummaryStats mismatch (-want +got):\n%s", diff) + } +} + +func TestDailyHandler(t *testing.T) { + 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) { + 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) { + 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) { + 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/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/api/v1/v1.go b/api/v1/v1.go index e4ffe1b3..e6cee6f9 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,7 @@ func Router() chi.Router { r.Mount("/health", health.Router()) r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) + r.Mount("/stats", stats.Router()) r.Mount("/users", users.Router()) r.Mount("/admin", admin.Router()) return r diff --git a/domain/dto/reversal.go b/domain/dto/reversal.go index 28db6941..e8c7362f 100644 --- a/domain/dto/reversal.go +++ b/domain/dto/reversal.go @@ -13,6 +13,10 @@ type ReversalListOptions struct { Cursor *Cursor Limit *uint OrderParam *OrderParam + // 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/domain/dto/stats.go b/domain/dto/stats.go new file mode 100644 index 00000000..c0cc0522 --- /dev/null +++ b/domain/dto/stats.go @@ -0,0 +1,17 @@ +package dto + +type SummaryStats struct { + // 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"` +} + +type DailyCount struct { + Date string `json:"date"` + Count uint64 `json:"count"` +} 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 13520fa8..4450016c 100644 --- a/domain/repository/public.go +++ b/domain/repository/public.go @@ -13,4 +13,13 @@ 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) +} + +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 cebda95d..63f28b8c 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" @@ -94,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) @@ -101,6 +111,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 { @@ -123,3 +136,65 @@ 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) 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 + } + + // "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 +} + +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 1 + ORDER BY 1 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 +} diff --git a/repository/public/reversal_test.go b/repository/public/reversal_test.go index 4f5fc6c0..14b77994 100644 --- a/repository/public/reversal_test.go +++ b/repository/public/reversal_test.go @@ -812,6 +812,326 @@ 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() + + 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", + }, + ) + + // "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{ + SteamIDsSearched: 2, + TotalSearches: 5, + 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_List_ExcludeExpunged(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 + opts *dto.ReversalListOptions + wantIDs []models.Snowflake + }{ + { + name: "newestFirstExcludingExpunged", + opts: &dto.ReversalListOptions{ + ExcludeExpunged: true, + OrderParam: &dto.OrderParam{ + Column: "id", + Direction: dto.DESC, + }, + }, + wantIDs: []models.Snowflake{5, 4, 2, 1}, + }, + { + 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.List(tc.opts) + if err != nil { + t.Fatalf("List(): %v", err) + } + if 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("List()[%d].ID = %d, want %d", i, got[i].ID, wantID) + } + } + }) + } +} + func TestReversalRepository_List_Pagination(t *testing.T) { t.Parallel() 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) + } +}