diff --git a/README.md b/README.md index 1926b7f..4e60dc3 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 0b3a294..d6d2f92 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 0000000..494325e --- /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 cb710d0..5af5752 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 0000000..ab0b8ad --- /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 0000000..1d763c6 --- /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 0000000..df9af69 --- /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 e4ffe1b..a104fac 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 0000000..b263bdb --- /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 13520fa..96645a9 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 cebda95..272667a 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 4f5fc6c..bba5e64 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()