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
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 @@ -77,11 +77,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: dto.OrderByCol("id", dto.DESC),
}

if steamIdStr := values.Get("steam_id"); steamIdStr != "" {
Expand Down Expand Up @@ -236,6 +233,63 @@ 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,
// 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).
OrderBy: dto.OrderByDesc("reversed_at", "id"),
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