Skip to content
4 changes: 2 additions & 2 deletions README.md
Comment thread
zedimytch marked this conversation as resolved.
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 Down Expand Up @@ -47,4 +47,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.
64 changes: 59 additions & 5 deletions api/v1/reversals/reversals.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"reverse-watch/render"

"github.com/go-chi/chi/v5"
"gorm.io/gorm/clause"
)

func createReversals(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -77,11 +78,8 @@ func createReversals(w http.ResponseWriter, r *http.Request) {

func listReversals(f repository.Factory, values url.Values, defaultLimit, maxLimit uint) ([]*models.Reversal, *dto.Cursor, error) {
opts := &dto.ReversalListOptions{
Limit: &defaultLimit,
OrderParam: &dto.OrderParam{
Column: "id",
Direction: dto.DESC,
},
Limit: &defaultLimit,
OrderBy: &clause.OrderBy{Columns: []clause.OrderByColumn{{Column: clause.Column{Name: "id"}, Desc: true}}},
}

if steamIdStr := values.Get("steam_id"); steamIdStr != "" {
Expand Down Expand Up @@ -236,6 +234,62 @@ 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"`
}

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,
OrderBy: &clause.OrderBy{Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: "reversed_at"}, Desc: true},
{Column: clause.Column{Name: "id"}, Desc: true},
}},
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,
})
}

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"}
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")
}
}
32 changes: 19 additions & 13 deletions api/v1/reversals/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading