From 4c6568eff658abe4be5bbf2fa58cb468df63c29d Mon Sep 17 00:00:00 2001 From: mateo <33040884+mateo19182@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:28:46 +0200 Subject: [PATCH 1/7] feat(item): add reactivate for expired own listings Expired listings 404 on the item detail and page, so they could not be shown, edited or relisted. item reactivate sends the web's PUT /api/v3/items/{hash}/reactivate, checking ownership against /api/v3/user/items (now decoding expired.flag) instead of the detail. --- README.md | 2 +- docs-site/content/docs/reference/item.mdx | 8 +++++ docs/cli-spec.md | 7 ++++- internal/cli/cli_test.go | 31 +++++++++++++++++++ internal/cli/item.go | 26 ++++++++++++++++ internal/cli/skills/wallapop-usage.md | 3 +- internal/fakewallapop/fake.go | 18 ++++++++++-- internal/mcp/tools.go | 1 + internal/wallapop/items.go | 36 ++++++++++++++++++++++- 9 files changed, 125 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f8cb827..c872c24 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ eligible interactive commands check for a new stable release at most once a day | `category list` | category ids | | `alert list` | your saved searches on wallapop; `watch add search --from-alert ID --name NAME` turns one into a watch | | `item show / images / open / favorite / unfavorite` | inspect a listing by hash or url | -| `item reserve / sold / delete` | act on your own listings (`sold`, `delete` ask or need `--yes`) | +| `item reserve / reactivate / sold / delete` | act on your own listings (`reactivate` relists an expired one; `sold`, `delete` ask or need `--yes`) | | `user show / items / reviews` | look at a seller by hash, url or slug | | `me show / items / favorites` | your account | | `chat list / show / send / start / open / archive` | messaging; `open` is a live line-mode chat | diff --git a/docs-site/content/docs/reference/item.mdx b/docs-site/content/docs/reference/item.mdx index 737619b..84e9bcf 100644 --- a/docs-site/content/docs/reference/item.mdx +++ b/docs-site/content/docs/reference/item.mdx @@ -108,6 +108,14 @@ Open the listing in the browser wallapop item open ITEM ``` +## `wallapop item reactivate` + +Relist one of your own expired listings + +```bash +wallapop item reactivate ITEM +``` + ## `wallapop item reserve` Mark one of your own listings as reserved (or --off) diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 255b22c..c7f19c2 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -27,7 +27,7 @@ profile list | use | remove search [keywords...] | filters category list alert list -item show | open | favorite | unfavorite | reserve | sold | delete +item show | open | favorite | unfavorite | reserve | reactivate | sold | delete user show | items | reviews me show | items | favorites chat list | show | send | start | open | archive @@ -182,6 +182,11 @@ detail with the page flags. as a refused write, exit 1, not as a dead session. Verified live 2026-09-15 on a throwaway listing: reserve, unreserve, sold (moves to `me items --sold`) and delete (page and API 404 afterwards), each reflected on the web. +- `item reactivate ITEM` relists one of the account's own expired Items with + `PUT /api/v3/items/{hash}/reactivate {}` (204, the web's "Reactivar"). Expired Items 404 on + the detail and page, so ownership is checked against `GET /api/v3/user/items` (`expired.flag`): + not found is exit 2 with no write, not expired prints `already active`. Verified live + 2026-10-24. - `item create --title T --description D --price P --category LEAF --condition C --image F... [--attr k=v...]` publishes with images: `POST /api/v3/items/upload/components` opens a client-UUID upload session, each picture goes to `POST /api/v3/upload/{uuid}/pictures` (multipart `image` part, 204), then `POST /api/v3/items` sends multipart (the first `image` plus an `item` JSON part: attributes, `category_leaf_id` as string, location, `upload_id`) with `Accept: application/vnd.upload-v2+json` (`application/json` answers 405), returning 200 `{"id"}`. Remaining images go to `POST /api/v3/items/{id}/picture2` with one multipart `image` and an `order` field starting at 1. Failed gallery uploads warn with the item id and picture number while returning the published item successfully. Once that id comes back the listing exists, so a failure to read it straight back is reported as a notice over the listing as sent, never as a failed create: exiting non-zero there would invite a retry that publishes twice. `--category` resolves against `GET /api/v3/categories?context=create` (leaf id or name); `--attr` keys validate against that leaf's inherited attribute list and are sent under the category's own spelling; title, description, condition and price_amount are refused there, since they have typed flags; a missing title, description, price, category, condition or image is a usage error (exit 2), as is `--lat` without `--lng` or the reverse. Verified live 2026-09-15: Books leaf 24097 published with photo, page 200, then edited, image-replaced, deleted (old page 404). - `item edit ITEM [--title T] [--description D] [--price P] [--condition C] [--category LEAF] [--attr k=v...] [--image F...]` overlays only the flags passed onto the account's own listing (`PUT /api/v3/items/{hash}`, same multipart shape, JSON body when no images) and refuses foreign listings (exit 2). The write replaces the whole attribute set, so the listing's existing category attributes go back untouched under the flags passed. It also resends the leaf category: a single-level taxonomy is its own leaf, a nested path is matched back against the create tree (detail APIs expose the path, not the leaf id), and a nested path that will not resolve is an error rather than a guess, since a root id sent as the leaf recategorises the listing. An explicit positive `--category` pins the leaf and controls attribute validation. Image replacement sends the first image in the edit, then the rest through `picture2` in order; later upload failures warn and return the edited item. Location is resent unchanged and omitted when the item carries none. No change flags is a usage error (exit 2). As with create, once the write lands a failed read-back is a notice over the listing with the overlay applied, not a failed edit. Verified live 2026-09-15 on the throwaway above: title+price edit, picture replacement, then delete; one older sofa listing (`vjrewv00elzk`) remained. ### user / me diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0991bd8..313a7e7 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1187,6 +1187,37 @@ func TestSellerActionsOnAnotherSellersItemFailBeforeWriting(t *testing.T) { } } +func TestItemReactivateRelistsExpiredOwnItem(t *testing.T) { + h := newHarness(t) + h.login() + mine := h.fake.AddItem(fakewallapop.Item{Hash: "hashexpired0", Slug: "old-thing-123", Title: "Old", Price: 5, Seller: fakewallapop.UserHash, Expired: true}) + if r := h.run("", "item", "show", mine.Hash); r.code != 4 { + t.Fatalf("an expired item should not resolve publicly before reactivation, got exit %d", r.code) + } + r := h.must("", "item", "reactivate", h.fake.URL+"/item/"+mine.Slug+"?utm_source=test") + var got struct{ Action, Item string } + decode(t, r.stdout, &got) + if got.Action != "reactivated" || got.Item != mine.Hash { + t.Fatalf("expected reactivated %s, got %s", mine.Hash, r.stdout) + } + h.must("", "item", "show", mine.Hash) + if r := h.must("", "item", "reactivate", mine.Hash); !strings.Contains(r.stdout, "already active") { + t.Fatalf("second reactivate should be a no-op, got %s", r.stdout) + } + if n := len(h.fake.RequestsUnder("/api/v3/items/" + mine.Hash + "/reactivate")); n != 1 { + t.Fatalf("expected one reactivate write, saw %d", n) + } + theirs := h.fake.AddItem(fakewallapop.Item{Hash: "hashtheirs01", Title: "Theirs", Price: 5, Seller: fakewallapop.OtherHash, Expired: true}) + if r := h.run("", "item", "reactivate", theirs.Hash); r.code != 2 || !strings.Contains(r.stderr, "not one of your listings") { + t.Fatalf("reactivating someone else's item must be a usage error, got exit %d stderr %q", r.code, r.stderr) + } + for _, rq := range h.fake.RequestsUnder("/api/v3/items/" + theirs.Hash) { + if rq.Method != "GET" { + t.Fatalf("no write may reach wallapop for someone else's item, saw %s %s", rq.Method, rq.Path) + } + } +} + func testPNG(t *testing.T, path string) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 4, 4)) diff --git a/internal/cli/item.go b/internal/cli/item.go index 6d4206f..dcef356 100644 --- a/internal/cli/item.go +++ b/internal/cli/item.go @@ -133,6 +133,7 @@ Examples: a.favoriteCmd("favorite", "Add the listing to your favorites", true), a.favoriteCmd("unfavorite", "Remove the listing from your favorites", false), a.reserveCmd(), + a.reactivateCmd(), a.destructiveItemCmd("sold", "sold", "Mark one of your own listings as sold", "Mark %s as sold? This cannot be undone on Wallapop.", // Closures, not method values: a.Client is built in setup, after this runs. func(ctx context.Context, hash string) error { return a.Client.MarkSold(ctx, hash) }), @@ -197,6 +198,31 @@ func (a *App) reserveCmd() *cobra.Command { return cmd } +// reactivateCmd relists an expired listing. ownedItemHash reads the public +// detail, which 404s while a listing is expired, so ownership comes from the +// account's own list instead. +func (a *App) reactivateCmd() *cobra.Command { + return &cobra.Command{ + Use: "reactivate ITEM", Short: "Relist one of your own expired listings", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := a.requireSession(); err != nil { + return err + } + it, err := a.Client.MyItem(cmd.Context(), args[0]) + if err != nil { + return err + } + if !it.Expired { + return a.Printer.Print(actionResult{Action: "already active", Item: it.Hash, OK: true}) + } + if err := a.Client.ReactivateItem(cmd.Context(), it.Hash); err != nil { + return err + } + return a.Printer.Print(actionResult{Action: "reactivated", Item: it.Hash, OK: true}) + }, + } +} + // ownedItemHash resolves ITEM and refuses it unless the active account listed // it. The check lives here because only the CLI knows the Profile's user hash; // Wallapop's own answer to a foreign write carries no explanation (see diff --git a/internal/cli/skills/wallapop-usage.md b/internal/cli/skills/wallapop-usage.md index 311ae4b..28433cf 100644 --- a/internal/cli/skills/wallapop-usage.md +++ b/internal/cli/skills/wallapop-usage.md @@ -48,6 +48,7 @@ Output: `{"items":[{hash,title,price,currency,reserved,sold,shippable,location,u wallapop item show HASH_OR_URL wallapop item favorite HASH | unfavorite HASH wallapop item reserve HASH [--off] | sold HASH --yes | delete HASH --yes # own items +wallapop item reactivate HASH_OR_URL # relist an own expired item wallapop user show USER | items USER | reviews USER # hash, profile URL or slug wallapop me show | items [--sold] | favorites ``` @@ -93,7 +94,7 @@ Install writes the server entry into `~/.claude.json`, `~/.codex/config.toml` or Tools: `search`, `item_show`, `user_show`, `watch_check`. Arguments are the command's flags with dashes as underscores (`max_price`, `no_mark_read`, `next_page` for `--next`); `condition` takes an array, `filter` an object. -Not tools, on purpose: `item reserve`, `item sold`, `item delete`, `item create`, `item edit`. Destructive and seller actions stay with the person at the terminal; asking for them returns a refusal that says so. The chat tools (`chat_list`, `chat_show`, `chat_send`, `chat_start`) are written but withheld until live message receive is verified. +Not tools, on purpose: `item reserve`, `item reactivate`, `item sold`, `item delete`, `item create`, `item edit`. Destructive and seller actions stay with the person at the terminal; asking for them returns a refusal that says so. The chat tools (`chat_list`, `chat_show`, `chat_send`, `chat_start`) are written but withheld until live message receive is verified. ## Config diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index d664269..9bbd64f 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -47,7 +47,10 @@ type Item struct { Seller string Reserved bool Sold bool - Removed bool // page 404s, API detail 404s + // Expired models a lapsed listing: listed under /user/items with + // expired.flag, while the public detail and page 404 until reactivated. + Expired bool + Removed bool // page 404s, API detail 404s // PageMissing models the window after a create where the API detail // answers but the rendered page has not propagated yet. PageMissing bool @@ -428,6 +431,7 @@ func (s *Server) userItemJSON(it *Item) map[string]any { "price": map[string]any{"amount": it.Price, "currency": "EUR"}, "shipping": map[string]bool{"item_is_shippable": true}, "reserved": map[string]bool{"flag": it.Reserved}, "sold": map[string]bool{"flag": it.Sold}, + "expired": map[string]bool{"flag": it.Expired}, } } @@ -533,11 +537,19 @@ func (s *Server) items(w http.ResponseWriter, r *http.Request) { it, ok := s.Items[hash] malformed := s.MalformedItem s.mu.Unlock() - if !ok || (it.Removed && action == "") { + if !ok || ((it.Removed || it.Expired) && action == "") { writeJSON(w, 404, map[string]any{"code": 404, "message": "item not found"}) return } switch { + case action == "reactivate" && r.Method == http.MethodPut: + if !s.authed(w, r) || !s.owned(w, it, 401) { + return + } + s.mu.Lock() + it.Expired = false + s.mu.Unlock() + w.WriteHeader(204) case action == "" && r.Method == http.MethodGet: if malformed { w.WriteHeader(200) @@ -644,7 +656,7 @@ func (s *Server) itemPage(w http.ResponseWriter, r *http.Request) { } } s.mu.Unlock() - if it == nil || it.Removed || it.PageMissing { + if it == nil || it.Removed || it.Expired || it.PageMissing { w.WriteHeader(404) fmt.Fprint(w, "gone") return diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index fcf0b66..596b1d6 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -54,6 +54,7 @@ var withheld = []struct{ name, why string }{ {"item_sold", "marking a listing sold is irreversible on Wallapop; run `wallapop item sold` yourself"}, {"item_delete", "deleting a listing is irreversible on Wallapop; run `wallapop item delete` yourself"}, {"item_reserve", "reserving a listing changes what buyers see; run `wallapop item reserve` yourself"}, + {"item_reactivate", "relisting an expired listing changes what buyers see; run `wallapop item reactivate` yourself"}, {"item_create", "publishing a listing is a seller action; run `wallapop item create` yourself"}, {"item_edit", "editing a listing is a seller action; run `wallapop item edit` yourself"}, } diff --git a/internal/wallapop/items.go b/internal/wallapop/items.go index f8149a6..05dd072 100644 --- a/internal/wallapop/items.go +++ b/internal/wallapop/items.go @@ -53,7 +53,7 @@ func (c *Client) Item(ctx context.Context, ref string) (Item, error) { return mergeItem(page, detail), nil } if !IsHash(ref) { - return Item{}, Usage("%q is not an item hash or item URL. Pass the 12-character hash or the full es.wallapop.com/item/... link", ref) + return Item{}, badItemRef(ref) } detail, err := c.itemFromAPI(ctx, ref) if err != nil { @@ -99,6 +99,30 @@ func mergeItem(page, detail Item) Item { return page } +func badItemRef(ref string) *Error { + return Usage("%q is not an item hash or item URL. Pass the 12-character hash or the full es.wallapop.com/item/... link", ref) +} + +// MyItem finds one of the account's own unsold listings by hash or item URL +// without touching the public detail, which 404s while a listing is expired. +// A listing not in that list is a usage error, like a foreign one elsewhere. +func (c *Client) MyItem(ctx context.Context, ref string) (Item, error) { + slug, isURL := itemSlugFromRef(ref) + if !isURL && !IsHash(ref) { + return Item{}, badItemRef(ref) + } + mine, err := c.MyItems(ctx, false) + if err != nil { + return Item{}, err + } + for _, it := range mine { + if it.Hash == ref || (isURL && pathTail(it.URL) == slug) { + return it, nil + } + } + return Item{}, Usage("%s is not one of your listings (see `wallapop me items`)", ref) +} + // ResolveItemHash turns any accepted item reference into its hash. func (c *Client) ResolveItemHash(ctx context.Context, ref string) (string, error) { if IsHash(ref) { @@ -368,6 +392,14 @@ func ownWriteError(err error) error { return err } +// ReactivateItem relists one of the account's own expired items. Mirrors the +// web catalog's "Reactivar" (PUT .../reactivate with an empty object). An +// expired item 404s on the public detail endpoint until this succeeds. +func (c *Client) ReactivateItem(ctx context.Context, hash string) error { + _, err := c.do(ctx, request{method: http.MethodPut, base: c.APIBase, path: "/api/v3/items/" + hash + "/reactivate", body: map[string]any{}, auth: true}, nil) + return ownWriteError(err) +} + // MarkSold marks one of the account's own items as sold. Irreversible on Wallapop. func (c *Client) MarkSold(ctx context.Context, hash string) error { _, err := c.do(ctx, request{method: http.MethodPut, base: c.APIBase, path: "/api/v3/items/" + hash + "/sold", auth: true}, nil) @@ -397,6 +429,7 @@ type userItemsResponse struct { } `json:"shipping"` Reserved flag `json:"reserved"` Sold flag `json:"sold"` + Expired flag `json:"expired"` UserID string `json:"user_id"` } `json:"data"` Meta struct { @@ -424,6 +457,7 @@ func (c *Client) userItems(ctx context.Context, path string, auth bool, sellerHa SellerHash: firstNonEmpty(d.UserID, sellerHash), Reserved: d.Reserved.Flag, Sold: d.Sold.Flag, + Expired: d.Expired.Flag, Shippable: d.Shipping.ItemIsShippable, Images: d.Images.bigURLs(), URL: c.itemURL(d.Slug), From ef0127f0b55eb2f45b5d34a5475efd6fabf208bf Mon Sep 17 00:00:00 2001 From: mateo <33040884+mateo19182@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:41 +0200 Subject: [PATCH 2/7] fix(item): page /user/items and accept numeric URL aliases in reactivate /user/items pages 40 at a time via meta.next and ?since= (verified live on /users/{hash}/items, same shape as the web's favorites cursor), so a listing past the first page was reported as not owned. An item URL may also carry only the numeric id (/item/123), which now matches the canonical slug's trailing id. --- internal/cli/cli_test.go | 14 ++++++++++ internal/fakewallapop/fake.go | 20 +++++++++++--- internal/wallapop/items.go | 52 ++++++++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 313a7e7..0418733 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1218,6 +1218,20 @@ func TestItemReactivateRelistsExpiredOwnItem(t *testing.T) { } } +func TestItemReactivateFindsListingOnLaterPageByNumericURL(t *testing.T) { + h := newHarness(t) + h.login() + h.fake.UserItemsPageSize = 1 + h.fake.AddItem(fakewallapop.Item{Hash: "hashactive00", Slug: "active-thing-111", Title: "Active", Price: 5, Seller: fakewallapop.UserHash}) + mine := h.fake.AddItem(fakewallapop.Item{Hash: "hashexpired1", Slug: "old-thing-222", Title: "Old", Price: 5, Seller: fakewallapop.UserHash, Expired: true}) + r := h.must("", "item", "reactivate", h.fake.URL+"/item/222") + var got struct{ Action, Item string } + decode(t, r.stdout, &got) + if got.Action != "reactivated" || got.Item != mine.Hash { + t.Fatalf("expected reactivated %s, got %s", mine.Hash, r.stdout) + } +} + func testPNG(t *testing.T, path string) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 4, 4)) diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index 9bbd64f..9429a2f 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -123,9 +123,11 @@ type Server struct { // NewItemPageLags makes a created listing's rendered page 404 while its // API detail already answers, the propagation window Wallapop really has. NewItemPageLags bool - Uploads map[string]int // pictures received per upload id - WebhookStatus int // status for POST /hook (0 = 200) - Hooks []json.RawMessage + // UserItemsPageSize pages /user/items via meta.next and ?since= (zero: one page). + UserItemsPageSize int + Uploads map[string]int // pictures received per upload id + WebhookStatus int // status for POST /hook (0 = 200) + Hooks []json.RawMessage } func New() *Server { @@ -257,7 +259,17 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { if data == nil { data = []map[string]any{} } - writeJSON(w, 200, map[string]any{"data": data, "meta": map[string]any{}}) + meta := map[string]any{} + if n := s.UserItemsPageSize; n > 0 && p != "/api/v3/user/items/favorited" { + start, _ := strconv.Atoi(r.URL.Query().Get("since")) + start = min(start, len(data)) + end := min(start+n, len(data)) + if end < len(data) { + meta["next"] = strconv.Itoa(end) + } + data = data[start:end] + } + writeJSON(w, 200, map[string]any{"data": data, "meta": meta}) case p == "/api/v3/items" && r.Method == http.MethodPost: s.createItem(w, r) case p == "/api/v3/items/upload/components": diff --git a/internal/wallapop/items.go b/internal/wallapop/items.go index 05dd072..8cb9851 100644 --- a/internal/wallapop/items.go +++ b/internal/wallapop/items.go @@ -105,24 +105,45 @@ func badItemRef(ref string) *Error { // MyItem finds one of the account's own unsold listings by hash or item URL // without touching the public detail, which 404s while a listing is expired. +// URLs match on the slug or its trailing numeric id (Wallapop's URL alias). // A listing not in that list is a usage error, like a foreign one elsewhere. func (c *Client) MyItem(ctx context.Context, ref string) (Item, error) { slug, isURL := itemSlugFromRef(ref) if !isURL && !IsHash(ref) { return Item{}, badItemRef(ref) } - mine, err := c.MyItems(ctx, false) - if err != nil { - return Item{}, err - } - for _, it := range mine { - if it.Hash == ref || (isURL && pathTail(it.URL) == slug) { - return it, nil + next := "" + for page := 0; page < maxOwnItemPages; page++ { + mine, cursor, err := c.userItemsPage(ctx, "/api/v3/user/items", next, true, "") + if err != nil { + return Item{}, err + } + for _, it := range mine { + if it.Hash == ref || (isURL && slugMatches(pathTail(it.URL), slug)) { + return it, nil + } + } + if cursor == "" || cursor == next { + break } + next = cursor } return Item{}, Usage("%s is not one of your listings (see `wallapop me items`)", ref) } +// maxOwnItemPages bounds the walk through /user/items (40 per page). +const maxOwnItemPages = 50 + +// slugMatches compares a canonical slug ("old-thing-123") with a reference +// that is either that slug or its trailing numeric id ("123"). +func slugMatches(canonical, ref string) bool { + if canonical == ref { + return true + } + i := strings.LastIndex(canonical, "-") + return i >= 0 && canonical[i+1:] == ref +} + // ResolveItemHash turns any accepted item reference into its hash. func (c *Client) ResolveItemHash(ctx context.Context, ref string) (string, error) { if IsHash(ref) { @@ -438,9 +459,20 @@ type userItemsResponse struct { } func (c *Client) userItems(ctx context.Context, path string, auth bool, sellerHash string) ([]Item, error) { + items, _, err := c.userItemsPage(ctx, path, "", auth, sellerHash) + return items, err +} + +// userItemsPage reads one page; next is meta.next, passed back as ?since= +// for the following page (the web's cursor, 40 items per page). +func (c *Client) userItemsPage(ctx context.Context, path, since string, auth bool, sellerHash string) (items []Item, next string, err error) { + var q url.Values + if since != "" { + q = url.Values{"since": {since}} + } var raw userItemsResponse - if err := c.getJSON(ctx, path, nil, auth, &raw); err != nil { - return nil, err + if err := c.getJSON(ctx, path, q, auth, &raw); err != nil { + return nil, "", err } out := make([]Item, 0, len(raw.Data)) for _, d := range raw.Data { @@ -463,7 +495,7 @@ func (c *Client) userItems(ctx context.Context, path string, auth bool, sellerHa URL: c.itemURL(d.Slug), }) } - return out, nil + return out, raw.Meta.Next, nil } // MyItems lists the account's active (or sold) listings. From 2c4a0be78bb829f72084d0b37bfbd4b8d8ce6030 Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 1 Nov 2026 10:17:34 -0500 Subject: [PATCH 3/7] Refactor pagination check for user items retrievalfix(item): report incomplete ownership lookup at the page cap Update logic to determine if more pages are available for user items. --- internal/wallapop/items.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/wallapop/items.go b/internal/wallapop/items.go index 8cb9851..f97d44d 100644 --- a/internal/wallapop/items.go +++ b/internal/wallapop/items.go @@ -113,6 +113,7 @@ func (c *Client) MyItem(ctx context.Context, ref string) (Item, error) { return Item{}, badItemRef(ref) } next := "" + more := false for page := 0; page < maxOwnItemPages; page++ { mine, cursor, err := c.userItemsPage(ctx, "/api/v3/user/items", next, true, "") if err != nil { @@ -123,11 +124,15 @@ func (c *Client) MyItem(ctx context.Context, ref string) (Item, error) { return it, nil } } - if cursor == "" || cursor == next { + more = cursor != "" && cursor != next + if !more { break } next = cursor } + if more { + return Item{}, Usage("%s was not found in the first %d pages of your listings", ref, maxOwnItemPages) + } return Item{}, Usage("%s is not one of your listings (see `wallapop me items`)", ref) } From c51154982eb9d1df742239b1a13ca8fae45236a9 Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 1 Nov 2026 10:18:58 -0500 Subject: [PATCH 4/7] test(fake): clamp negative owned-item pagination cursors --- internal/fakewallapop/fake.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index 9429a2f..c2d7170 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -262,7 +262,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { meta := map[string]any{} if n := s.UserItemsPageSize; n > 0 && p != "/api/v3/user/items/favorited" { start, _ := strconv.Atoi(r.URL.Query().Get("since")) - start = min(start, len(data)) + start = min(max(start, 0), len(data)) end := min(start+n, len(data)) if end < len(data) { meta["next"] = strconv.Itoa(end) From 548e410d318310fef72faf3ff1afd3018b169eef Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 1 Nov 2026 10:20:31 -0500 Subject: [PATCH 5/7] test(item): keep capped ownership lookups read-only --- internal/cli/cli_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0418733..0f3cb75 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1604,3 +1604,22 @@ func TestSearchWatchDeduplicatesRepeatedItemsWithinCheck(t *testing.T) { t.Fatalf("second check should be silent: %s", r.stdout) } } + +func TestItemReactivateReportsIncompleteOwnershipLookup(t *testing.T) { + h := newHarness(t) + h.login() + h.fake.UserItemsPageSize = 1 + for i := 0; i < 51; i++ { + h.fake.AddItem(fakewallapop.Item{Hash: fmt.Sprintf("%012d", i), Title: "Expired", Seller: fakewallapop.UserHash, Expired: true}) + } + r := h.run("", "item", "reactivate", "000000000050") + if r.code != 2 || !strings.Contains(r.stderr, "first 50 pages") { + t.Fatalf("expected an explicit lookup cap, got exit %d stderr %q", r.code, r.stderr) + } + if n := len(h.fake.RequestsUnder("/api/v3/user/items")); n != 50 { + t.Fatalf("expected bounded lookup of 50 pages, saw %d", n) + } + if n := len(h.fake.RequestsUnder("/api/v3/items/000000000050/reactivate")); n != 0 { + t.Fatalf("incomplete ownership lookup must not write, saw %d requests", n) + } +} From c940118bd8f2333698ce8ea71f51394a1798f3cf Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 1 Nov 2026 10:21:37 -0500 Subject: [PATCH 6/7] test(fake): cover negative pagination cursor recovery --- internal/fakewallapop/fake_test.go | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 internal/fakewallapop/fake_test.go diff --git a/internal/fakewallapop/fake_test.go b/internal/fakewallapop/fake_test.go new file mode 100644 index 0000000..df4debb --- /dev/null +++ b/internal/fakewallapop/fake_test.go @@ -0,0 +1,35 @@ +package fakewallapop + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestUserItemsNegativeCursorReturnsFirstPage(t *testing.T) { + s := New() + defer s.Close() + s.UserItemsPageSize = 1 + s.AddItem(Item{Hash: "hashactive00", Title: "Active", Seller: UserHash}) + req, err := http.NewRequest(http.MethodGet, s.URL+"/api/v3/user/items?since=-1", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer access-token") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var body struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK || len(body.Data) != 1 || body.Data[0].ID != "hashactive00" { + t.Fatalf("expected first page, got status %d body %+v", res.StatusCode, body) + } +} From 7eb900ed4e6f57ba9024996d70a8ce67d1fbeb79 Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 1 Nov 2026 10:24:48 -0500 Subject: [PATCH 7/7] fix(release): migrate Homebrew install hook to supported steps --- .goreleaser.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 78c742b..0e99bfd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -64,9 +64,9 @@ homebrew_casks: # unless the quarantine flag Homebrew sets on the download is removed. hooks: post: - install: | - if OS.mac? - system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/wallapop"] + install_steps: | + on_macos do + run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}/wallapop"] end scoops: