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
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.
49 changes: 49 additions & 0 deletions api/v1/reversals/reversals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
240 changes: 240 additions & 0 deletions api/v1/reversals/reversals_recent_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading