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
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"
)

const maxBatchSize = 1_000
Expand Down Expand Up @@ -87,11 +88,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 @@ -246,6 +244,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
257 changes: 257 additions & 0 deletions api/v1/reversals/reversals_recent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
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) {
Comment thread
ZukwiZ marked this conversation as resolved.
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"}
if len(row) != len(expectedKeys) {
t.Errorf("response keys = %v, want exactly %v", row, expectedKeys)
}
for _, k := range expectedKeys {
if _, ok := row[k]; !ok {
t.Errorf("missing 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")
}

marketplaceSlugValue, ok := row["marketplace_slug"].(string)
if !ok {
t.Errorf("marketplace_slug should be a JSON string, got %T", row["marketplace_slug"])
}
if marketplaceSlugValue != "csfloat" {
t.Errorf("marketplace_slug = %q, want %q", marketplaceSlugValue, "csfloat")
}

reversedAtValue, ok := row["reversed_at"].(float64)
if !ok {
t.Errorf("reversed_at should be a JSON number, got %T", row["reversed_at"])
}
if reversedAtValue != float64(base+50) {
t.Errorf("reversed_at = %v, want %v", reversedAtValue, float64(base+50))
}
}
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.ThrottleByMarketplace(time.Hour, 2_000),
).Post("/", createReversals)
r.With(ratelimit.ThrottleByIP(time.Minute, 30)).Get("/recent", listRecentHandler)

r.With(
middleware.RequirePermissions(models.PermissionDelete),
ratelimit.ThrottleByMarketplace(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.ThrottleByMarketplace(time.Hour, 2_000),
).Post("/", createReversals)

r.With(ratelimit.ThrottleByMarketplace(time.Minute, 300)).Get("/", listReversalsHandler)
r.With(ratelimit.ThrottleByMarketplace(time.Minute, 60)).Get("/export", exportReversals)
r.With(
middleware.RequirePermissions(models.PermissionDelete),
ratelimit.ThrottleByMarketplace(time.Hour, 2_000),
).Delete("/{id}", expungeReversal)

r.Route("/", func(r chi.Router) {
r.Use(middleware.RequirePermissions(models.PermissionExport))

r.With(ratelimit.ThrottleByMarketplace(time.Minute, 300)).Get("/", listReversalsHandler)
r.With(ratelimit.ThrottleByMarketplace(time.Minute, 60)).Get("/export", exportReversals)
})
})

return r
}
Loading
Loading