Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions README.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, we don't need to update this README. Please revert everything except for the misspell fix.

Original file line number Diff line number Diff line change
@@ -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?

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Rate limits are enforced in-memory per process. Throttled responses return `429 Too Many Requests` with `X-RateLimit-*` and `Retry-After` headers.
66 changes: 66 additions & 0 deletions api/v1/reversals/reversals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Comment thread
cursor[bot] marked this conversation as resolved.

@zedimytch zedimytch Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason for this secondary order param, am I missing something? Ordering by reversed_at should be all that you need.

Edit: It looks like you'll need the secondary ordering for pagination. In that case, we can change the OrderParam in the options struct to be of type clause.OrderBy.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think 'created_at' will be used by the frontend, can you clarify what this is for?

})
}

render.JSON(w, r, listRecentResponse{Data: data})
}
Comment thread
zedimytch marked this conversation as resolved.

func expungeReversal(w http.ResponseWriter, r *http.Request) {
factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory)
key := r.Context().Value(middleware.KeyContextKey).(*models.Key)
Expand Down
250 changes: 250 additions & 0 deletions api/v1/reversals/reversals_recent_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading