-
Notifications
You must be signed in to change notification settings - Fork 4
api/v1: public stats and recent reversals endpoints #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
9be036d
3b30881
8440a0d
8652b50
a1d8b76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| }, | ||
|
cursor[bot] marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| 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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) | ||
| } | ||
|
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) | ||
|
|
||
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
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.