From 382d0d73abc10ef9e73022cae8bd209262122845 Mon Sep 17 00:00:00 2001 From: Miguel Camba Date: Thu, 8 Oct 2026 10:45:11 +0200 Subject: [PATCH 1/2] item create/edit: upload remaining gallery images via picture2 Wallapop's current API only attaches the first `image` part of the multipart POST/PUT to /api/v3/items, so `item create --image a --image b --image c` published listings with a single picture (the extra session uploads to /api/v3/upload/{uuid}/pictures were ignored). The web's Angular upload app does it in two steps (upload service, processQueue): the create call carries just the first image, and the rest of the gallery go to POST /api/v3/items/{id}/picture2 as multipart with a plain `order` field (1-based position) plus the image under `image`. This mirrors that: create/edit send only the leading picture, then picture2 carries the remaining gallery in order. A failed picture upload no longer fails the whole command; it warns and returns the item. Also adds `item edit --category `: the item detail exposes the localized taxonomy path, which can fail to resolve against the create tree, so callers can now pin the leaf id explicitly. Includes a hidden `debug-steps` command used to explore Wallapop's new server-driven listing wizard (POST /api/v3/steps), which is how this was diagnosed. --- docs-site/content/docs/reference/item.mdx | 1 + internal/cli/cli_test.go | 11 ++ internal/cli/debug_steps.go | 95 +++++++++++++++++ internal/cli/item.go | 13 ++- internal/cli/root.go | 1 + internal/fakewallapop/fake.go | 31 ++++++ internal/wallapop/picture2.go | 120 ++++++++++++++++++++++ internal/wallapop/publish.go | 56 ++++++++-- internal/wallapop/steps.go | 36 +++++++ 9 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 internal/cli/debug_steps.go create mode 100644 internal/wallapop/picture2.go create mode 100644 internal/wallapop/steps.go diff --git a/docs-site/content/docs/reference/item.mdx b/docs-site/content/docs/reference/item.mdx index 3f84e07..737619b 100644 --- a/docs-site/content/docs/reference/item.mdx +++ b/docs-site/content/docs/reference/item.mdx @@ -67,6 +67,7 @@ wallapop item edit ITEM [flags] | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--attr` | stringArray | `[]` | category attribute key=value (repeatable) | +| `--category` | int | `0` | leaf category id (see wallapop category list); skips taxonomy resolution | | `--condition` | string | - | new condition | | `--description` | string | - | new description | | `--image` | stringArray | `[]` | replacement image files (repeatable) | diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 65084ff..848de85 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1223,6 +1223,17 @@ func TestItemCreatePublishesWithImages(t *testing.T) { if total != 2 { t.Fatalf("uploaded pictures = %d, want 2", total) } + // The create carries only the first picture; the rest go to the created + // item's picture2 endpoint, the way the web's upload queue does. + var picture2 int + for _, rq := range h.fake.RequestsUnder("/api/v3/items/" + created.Hash) { + if strings.HasSuffix(rq.Path, "/picture2") && rq.Method == "POST" { + picture2++ + } + } + if picture2 != 1 { + t.Fatalf("picture2 calls = %d, want 1", picture2) + } listed := h.must("", "me", "items") if !strings.Contains(listed.stdout, created.Hash) { t.Fatalf("created item missing from me items:\n%s", listed.stdout) diff --git a/internal/cli/debug_steps.go b/internal/cli/debug_steps.go new file mode 100644 index 0000000..f465ff6 --- /dev/null +++ b/internal/cli/debug_steps.go @@ -0,0 +1,95 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Microck/wallapop-cli/internal/wallapop" + "github.com/spf13/cobra" +) + +// debugStepsCmd is a hidden experimental command that posts an arbitrary +// payload to /api/v3/steps and prints the raw response. Used to explore the +// server-driven listing wizard. +func (a *App) debugStepsCmd() *cobra.Command { + var payloadFile string + cmd := &cobra.Command{ + Use: "debug-steps", + Short: "Post a JSON payload to the steps endpoint and print the raw response", + Long: `Experimental: walk the server-driven listing wizard. + + wallapop debug-steps '' POST the JSON to /api/v3/steps + wallapop debug-steps --upload PATH f.jpg [f2.jpg ...] + POST multipart images to PATH`, + Hidden: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && args[0] == "get" { + out, err := a.Client.StepsGetRaw(cmd.Context(), args[1]) + if err != nil { + return err + } + fmt.Fprintf(a.Stdout, "%s\n", string(out)) + return nil + } + if len(args) > 1 && args[0] == "upload" { + imgs, err := loadUploadImages(args[2:]) + if err != nil { + return err + } + out, err := a.Client.StepsUploadRaw(cmd.Context(), args[1], imgs) + if err != nil { + return err + } + fmt.Fprintf(a.Stdout, "%s\n", string(out)) + return nil + } + var payload any + if payloadFile != "" { + raw, err := os.ReadFile(payloadFile) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("bad payload JSON: %w", err) + } + } else if len(args) > 0 { + if err := json.Unmarshal([]byte(args[0]), &payload); err != nil { + return fmt.Errorf("bad payload JSON: %w", err) + } + } else { + payload = map[string]any{} + } + out, err := a.Client.StepsRaw(cmd.Context(), payload) + if err != nil { + if we, ok := err.(*wallapop.Error); ok && we.Body != "" { + fmt.Fprintf(a.Stderr, "HTTP %d body: %s\n", we.Status, we.Body) + } + return err + } + fmt.Fprintln(a.Stdout, string(out)) + return nil + }, + } + cmd.Flags().StringVar(&payloadFile, "payload-file", "", "read the JSON payload from a file") + return cmd +} + +func loadUploadImages(paths []string) ([]wallapop.UploadImage, error) { + var imgs []wallapop.UploadImage + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + return nil, err + } + ct := "image/jpeg" + if strings.HasSuffix(strings.ToLower(p), ".png") { + ct = "image/png" + } + imgs = append(imgs, wallapop.UploadImage{Name: filepath.Base(p), Data: data, ContentType: ct}) + } + return imgs, nil +} diff --git a/internal/cli/item.go b/internal/cli/item.go index 5c56bd2..4c8422b 100644 --- a/internal/cli/item.go +++ b/internal/cli/item.go @@ -347,6 +347,7 @@ func (a *App) itemEditCmd() *cobra.Command { var price float64 var images []string var attrs []string + var categoryID int cmd := &cobra.Command{ Use: "edit ITEM", Short: "Change title, description, price, condition or images on your listing", @@ -356,8 +357,8 @@ func (a *App) itemEditCmd() *cobra.Command { return err } fl := cmd.Flags() - if !fl.Changed("title") && !fl.Changed("description") && !fl.Changed("price") && !fl.Changed("condition") && len(attrs) == 0 && len(images) == 0 { - return output.Usagef("nothing to change. Pass at least one of --title, --description, --price, --condition, --attr, --image") + if !fl.Changed("title") && !fl.Changed("description") && !fl.Changed("price") && !fl.Changed("condition") && !fl.Changed("category") && len(attrs) == 0 && len(images) == 0 { + return output.Usagef("nothing to change. Pass at least one of --title, --description, --price, --condition, --category, --attr, --image") } hash, err := a.ownedItemHash(cmd.Context(), args[0]) if err != nil { @@ -376,6 +377,13 @@ func (a *App) itemEditCmd() *cobra.Command { if fl.Changed("condition") { in.Condition = &condition } + if fl.Changed("category") { + // The item detail exposes the taxonomy path, not the leaf id, + // and the path can fail to resolve when Wallapop localizes + // category names differently between endpoints. Let the + // caller pin the leaf explicitly. + in.CategoryLeaf = categoryID + } if len(attrs) > 0 { it, err := a.Client.Item(cmd.Context(), hash) if err != nil { @@ -426,6 +434,7 @@ func (a *App) itemEditCmd() *cobra.Command { cmd.Flags().StringVar(&condition, "condition", "", "new condition") cmd.Flags().StringArrayVar(&images, "image", nil, "replacement image files (repeatable)") cmd.Flags().StringArrayVar(&attrs, "attr", nil, "category attribute key=value (repeatable)") + cmd.Flags().IntVar(&categoryID, "category", 0, "leaf category id (see wallapop category list); skips taxonomy resolution") return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index 33ee0e2..9aeb932 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -154,6 +154,7 @@ Agent usage: wallapop skills get wallapop-usage`, a.mcpCmd(), a.watchCmd(), a.sinkCmd(), a.configCmd(), a.doctorCmd(), a.skillsCmd(), a.updateCmd(), + a.debugStepsCmd(), ) return root } diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index de683dc..4ded7ee 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -590,6 +590,37 @@ func (s *Server) items(w http.ResponseWriter, r *http.Request) { it.Sold = true s.mu.Unlock() w.WriteHeader(204) + case action == "picture2" && r.Method == http.MethodPost: + // Mirrors the web's upload queue: multipart with one `image` part and + // a plain `order` field (1-based gallery position). Appends the image. + if !s.authed(w, r) || !s.owned(w, it, 403) { + return + } + mr, err := r.MultipartReader() + if err != nil { + writeJSON(w, 400, map[string]any{"code": 400, "message": "not multipart"}) + return + } + added := 0 + for { + part, err := mr.NextPart() + if err != nil { + break + } + if part.FormName() == "image" { + _, _ = io.Copy(io.Discard, part) + added++ + } + } + if added == 0 { + writeJSON(w, 400, map[string]any{"code": 400, "message": "image required"}) + return + } + s.mu.Lock() + it.Images += added + it.Modified = time.Now() + s.mu.Unlock() + w.WriteHeader(204) case action == "" && r.Method == http.MethodPut: s.editItem(w, r, it) default: diff --git a/internal/wallapop/picture2.go b/internal/wallapop/picture2.go new file mode 100644 index 0000000..b1056e7 --- /dev/null +++ b/internal/wallapop/picture2.go @@ -0,0 +1,120 @@ +package wallapop + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" +) + +// uploadItemPicture attaches one picture to an already-created item, the way +// the web's upload queue does: POST /api/v3/items/{id}/picture2, multipart +// with a plain `order` field (1-based position in the gallery, the create +// call having consumed position 0) plus the image under the `image` field. +// Recorded from the Angular upload app (chunk-2K77EQS6.js, processQueue). +func (c *Client) uploadItemPicture(ctx context.Context, itemID string, order int, img UploadImage) error { + endpoint := "POST /api/v3/items/" + itemID + "/picture2" + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("order", strconv.Itoa(order)); err != nil { + return err + } + ct := img.ContentType + if ct == "" { + ct = "application/octet-stream" + } + fw, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": {fmt.Sprintf(`form-data; name="image"; filename="%s"`, quoteEscaper.Replace(img.Name))}, + "Content-Type": {ct}, + }) + if err != nil { + return err + } + if _, err := fw.Write(img.Data); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + + if c.Tokens == nil { + return &Error{Kind: KindAuth, Endpoint: endpoint, Msg: "this command needs a logged-in profile. Run `wallapop auth login`"} + } + tok, err := c.Tokens.AccessToken(ctx) + if err != nil { + return err + } + c.Redact(tok) + + build := func() (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.APIBase+"/api/v3/items/"+itemID+"/picture2", bytes.NewReader(buf.Bytes())) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.currentUA()) + req.Header.Set("Accept", UploadAccept) + req.Header.Set("X-DeviceOS", "0") + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + return req, nil + } + req, err := build() + if err != nil { + return err + } + // The endpoint answers 2xx with a body the client does not need (the web + // ignores it too), so no JSON decoding. + _, err = c.doRequest(ctx, req, build, endpoint, nil) + return err +} + +// doRequest mirrors Client.do's transport handling for pre-built requests. +// Minimal shim: send, retry once with the browser UA on CloudFront blocks, +// decode JSON into out and classify failures. +func (c *Client) doRequest(ctx context.Context, req *http.Request, rebuild func() (*http.Request, error), endpoint string, out any) (*http.Response, error) { + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, c.netError(endpoint, err) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + resp.Body.Close() + if err != nil { + return nil, c.netError(endpoint, err) + } + c.debugf("< %d %s (%d bytes)", resp.StatusCode, endpoint, len(raw)) + if resp.StatusCode == http.StatusForbidden && isCloudFrontBlock(raw) && !c.browserUAActive() { + c.setBrowserUA() + if c.Notice != nil { + c.Notice("wallapop rejected the wallapop-cli user agent; retrying with a browser user agent for this run") + } + req, err = rebuild() + if err != nil { + return nil, err + } + resp, err = c.HTTP.Do(req) + if err != nil { + return nil, c.netError(endpoint, err) + } + raw, err = io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + resp.Body.Close() + if err != nil { + return nil, c.netError(endpoint, err) + } + c.debugf("< %d %s (%d bytes, browser UA)", resp.StatusCode, endpoint, len(raw)) + } + ok := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !ok { + return resp, c.statusError(endpoint, resp.StatusCode, raw) + } + if out != nil && len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return resp, &Error{Kind: KindAPIChanged, Status: resp.StatusCode, Endpoint: endpoint, Body: "decode: " + err.Error() + "; body: " + c.snippet(raw), Msg: "wallapop returned a response the cli does not understand"} + } + } + return resp, nil +} diff --git a/internal/wallapop/publish.go b/internal/wallapop/publish.go index 4ed4caf..478e2da 100644 --- a/internal/wallapop/publish.go +++ b/internal/wallapop/publish.go @@ -119,12 +119,32 @@ func (c *Client) CreateItem(ctx context.Context, in CreateInput) (Item, error) { var out struct { ID string `json:"id"` } - if err := c.writeItem(ctx, http.MethodPost, "/api/v3/items", UploadAccept, payload, in.Images, &out); err != nil { + // The create call carries only the first image: Wallapop reads a single + // `image` part here and the web app uploads the rest of the gallery to + // the created item afterwards (see uploadRemainingImages in its upload + // service). Extra parts are silently ignored. + createImages := in.Images + if len(createImages) > 1 { + createImages = createImages[:1] + } + if err := c.writeItem(ctx, http.MethodPost, "/api/v3/items", UploadAccept, payload, createImages, &out); err != nil { return Item{}, err } if out.ID == "" { return Item{}, &Error{Kind: KindAPIChanged, Endpoint: "POST /api/v3/items", Msg: "wallapop returned no item id"} } + // Gallery order: the create consumed position 0, the rest go to + // picture2 with their 1-based positions. A failure here leaves a live + // listing missing pictures, so report it but keep the item. + var picErr error + for i, img := range imagesFrom(in.Images, 1) { + if err := c.uploadItemPicture(ctx, out.ID, i+1, img); err != nil { + if c.Notice != nil { + c.Notice(fmt.Sprintf("published %s, but picture %d failed to upload: %v", out.ID, i+2, err)) + } + picErr = err + } + } // The listing exists from here on. A fresh hash can take a moment to be // readable, and failing the command over that would invite a retry that // publishes the listing twice, so fall back to what was just sent. @@ -133,13 +153,13 @@ func (c *Client) CreateItem(ctx context.Context, in CreateInput) (Item, error) { if c.Notice != nil { c.Notice("published " + out.ID + ", but reading it back failed: " + err.Error()) } - return in.published(out.ID), nil + return in.published(out.ID), picErr } // A page 404 makes Client.Item infer "expired", which is right for a // listing Wallapop has hidden and wrong for one published seconds ago: // there the page simply has not propagated yet. it.Expired = false - return it, nil + return it, picErr } // published describes a listing straight from what was sent, for the window @@ -226,9 +246,24 @@ func (c *Client) EditItem(ctx context.Context, hash, ownerHash string, in EditIn payload["location"] = map[string]any{"latitude": current.Location.Lat, "longitude": current.Location.Lng, "approximated": false} } var out any - if err := c.writeItem(ctx, http.MethodPut, "/api/v3/items/"+current.Hash, UploadAccept, payload, in.Images, &out); err != nil { + editImages := in.Images + if len(editImages) > 1 { + editImages = editImages[:1] + } + if err := c.writeItem(ctx, http.MethodPut, "/api/v3/items/"+current.Hash, UploadAccept, payload, editImages, &out); err != nil { return Item{}, ownWriteError(err) } + // Like create: the write replaces the gallery with the first image, the + // rest go through picture2 in gallery order. + var picErr error + for i, img := range imagesFrom(in.Images, 1) { + if err := c.uploadItemPicture(ctx, current.Hash, i+1, img); err != nil { + if c.Notice != nil { + c.Notice(fmt.Sprintf("edited %s, but picture %d failed to upload: %v", current.Hash, i+2, err)) + } + picErr = err + } + } // The edit has landed. Like a create, failing the command because the // read-back raced the API would invite a pointless retry, so fall back to // the pre-edit listing with the overlay applied. @@ -245,9 +280,9 @@ func (c *Client) EditItem(ctx context.Context, hash, ownerHash string, in EditIn // them would be worse than reporting none. current.Images = nil } - return current, nil + return current, picErr } - return it, nil + return it, picErr } // editLeafID recovers the listing's leaf category for the write payload. @@ -528,3 +563,12 @@ func leafName(path string) string { } return strings.TrimSpace(path) } + +// imagesFrom returns the slice starting at index start without panicking on +// short slices. +func imagesFrom(imgs []UploadImage, start int) []UploadImage { + if start >= len(imgs) { + return nil + } + return imgs[start:] +} diff --git a/internal/wallapop/steps.go b/internal/wallapop/steps.go new file mode 100644 index 0000000..5a48142 --- /dev/null +++ b/internal/wallapop/steps.go @@ -0,0 +1,36 @@ +package wallapop + +import ( + "context" + "encoding/json" + "net/http" +) + +// StepsRaw posts an arbitrary payload to the steps wizard endpoint and +// returns the raw JSON response. Debug/experimental. +func (c *Client) StepsRaw(ctx context.Context, payload any) (json.RawMessage, error) { + var out json.RawMessage + _, err := c.do(ctx, request{ + method: http.MethodPost, + base: c.APIBase, + path: "/api/v3/steps", + body: payload, + auth: true, + }, &out) + return out, err +} + +// StepsGetRaw GETs an arbitrary API path and returns the raw JSON. +func (c *Client) StepsGetRaw(ctx context.Context, path string) (json.RawMessage, error) { + var out json.RawMessage + _, err := c.do(ctx, request{method: http.MethodGet, base: c.APIBase, path: path, auth: true}, &out) + return out, err +} + +// StepsUploadRaw posts image parts (multipart, field "image") to an +// arbitrary path, e.g. the photo step's picture_upload.path. +func (c *Client) StepsUploadRaw(ctx context.Context, path string, images []UploadImage) (json.RawMessage, error) { + var out json.RawMessage + err := c.writeItem(ctx, http.MethodPost, path, "application/json", nil, images, &out) + return out, err +} From 4deda9173459edaf9ca613d85905ede9d95e00f6 Mon Sep 17 00:00:00 2001 From: Microck Date: Thu, 8 Oct 2026 17:38:03 +0000 Subject: [PATCH 2/2] fix(item): preserve published output and validate edit inputs --- docs/cli-spec.md | 4 +- internal/cli/cli_test.go | 93 +++++++++++++++++++++++++++++++++++ internal/cli/debug_steps.go | 15 ++++-- internal/cli/item.go | 11 ++++- internal/fakewallapop/fake.go | 5 ++ internal/wallapop/publish.go | 12 ++--- 6 files changed, 124 insertions(+), 16 deletions(-) diff --git a/docs/cli-spec.md b/docs/cli-spec.md index f79995c..6a50a04 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -151,8 +151,8 @@ 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 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 (`image` files 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"}`. 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] [--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. 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. +- `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 - `user show USER`, `user items USER`, `user reviews USER` (public endpoints; USER is id or slug). diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 848de85..7d493f1 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -7,6 +7,8 @@ import ( "image" "image/png" "io" + "net/http" + "net/http/httptest" "net/url" "os" "path/filepath" @@ -1448,3 +1450,94 @@ func TestItemEditNoChangesIsUsageError(t *testing.T) { t.Fatalf("exit %d, want 2: %s", r.code, r.stderr) } } + +// The item already exists when picture2 fails. Keep its id in stdout so +// callers can recover without publishing a duplicate. +func TestItemGalleryFailureReturnsPublishedItem(t *testing.T) { + for _, action := range []string{"create", "edit"} { + t.Run(action, func(t *testing.T) { + h := newHarness(t) + h.login() + img := filepath.Join(h.home, "gallery.png") + testPNG(t, img) + h.fake.PictureStatus = 500 + args := []string{"item", action} + if action == "create" { + args = append(args, "--title", "Bike", "--description", "Bike", "--price", "10", "--category", "17001", "--condition", "good") + } else { + it := h.fake.AddItem(fakewallapop.Item{Hash: "hasheeeddddz", Title: "Bike", Seller: fakewallapop.UserHash}) + args = append(args, it.Hash) + } + args = append(args, "--image", img, "--image", img) + r := h.must("", args...) + var item struct { + Hash string + Images []string + } + decode(t, r.stdout, &item) + if len(item.Hash) != 12 || len(item.Images) != 1 { + t.Fatalf("missing published item or wrong gallery: %s", r.stdout) + } + if !strings.Contains(r.stderr, item.Hash) || !strings.Contains(r.stderr, "picture 2 failed") { + t.Fatalf("missing recovery warning: %s", r.stderr) + } + if action == "create" && h.fake.CreateCalls != 1 { + t.Fatalf("creates = %d", h.fake.CreateCalls) + } + }) + } +} + +func TestItemEditExplicitCategoryControlsAttrs(t *testing.T) { + h := newHarness(t) + h.login() + it := h.fake.AddItem(fakewallapop.Item{Hash: "hasheeeddddy", Title: "Bike", Seller: fakewallapop.UserHash}) + // Bikes accepts size; Cars accepts brand. The explicit leaf must win. + h.must("", "item", "edit", it.Hash, "--category", "100", "--attr", "brand=Volkswagen") + if got := h.fake.Items[it.Hash].Attrs["brand"]; got != "Volkswagen" { + t.Fatalf("brand = %q", got) + } + for _, category := range []string{"0", "-1"} { + r := h.run("", "item", "edit", it.Hash, "--category", category) + if r.code != 2 { + t.Fatalf("category %s: exit %d, stderr %s", category, r.code, r.stderr) + } + } +} + +func TestDebugStepsMissingArguments(t *testing.T) { + h := newHarness(t) + for _, args := range [][]string{{"get"}, {"upload"}, {"upload", "/api/v3/steps"}} { + r := h.run("", append([]string{"debug-steps"}, args...)...) + if r.code != 2 { + t.Fatalf("args %v: exit %d, stderr %s", args, r.code, r.stderr) + } + } +} + +func TestDebugStepsPreservesJSONNumbers(t *testing.T) { + h := newHarness(t) + h.login() + // Echo the actual HTTP body rather than decode it through float64. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v3/steps" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.Copy(w, r.Body) + return + } + h.fake.Config.Handler.ServeHTTP(w, r) + })) + defer server.Close() + t.Setenv("WALLAPOP_API_BASE_URL", server.URL) + payload := `{"id":9007199254740993}` + file := filepath.Join(h.home, "payload.json") + if err := os.WriteFile(file, []byte(payload), 0600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{payload}, {"--payload-file", file}} { + r := h.must("", append([]string{"debug-steps"}, args...)...) + if strings.TrimSpace(r.stdout) != payload { + t.Fatalf("payload changed: %s", r.stdout) + } + } +} diff --git a/internal/cli/debug_steps.go b/internal/cli/debug_steps.go index f465ff6..899d236 100644 --- a/internal/cli/debug_steps.go +++ b/internal/cli/debug_steps.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/Microck/wallapop-cli/internal/output" "github.com/Microck/wallapop-cli/internal/wallapop" "github.com/spf13/cobra" ) @@ -22,12 +23,15 @@ func (a *App) debugStepsCmd() *cobra.Command { Long: `Experimental: walk the server-driven listing wizard. wallapop debug-steps '' POST the JSON to /api/v3/steps - wallapop debug-steps --upload PATH f.jpg [f2.jpg ...] + wallapop debug-steps upload PATH f.jpg [f2.jpg ...] POST multipart images to PATH`, Hidden: true, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 && args[0] == "get" { + if len(args) != 2 { + return output.Usagef("debug-steps get requires one API path") + } out, err := a.Client.StepsGetRaw(cmd.Context(), args[1]) if err != nil { return err @@ -35,7 +39,10 @@ func (a *App) debugStepsCmd() *cobra.Command { fmt.Fprintf(a.Stdout, "%s\n", string(out)) return nil } - if len(args) > 1 && args[0] == "upload" { + if len(args) > 0 && args[0] == "upload" { + if len(args) < 3 { + return output.Usagef("debug-steps upload requires an API path and at least one image") + } imgs, err := loadUploadImages(args[2:]) if err != nil { return err @@ -47,7 +54,7 @@ func (a *App) debugStepsCmd() *cobra.Command { fmt.Fprintf(a.Stdout, "%s\n", string(out)) return nil } - var payload any + var payload json.RawMessage if payloadFile != "" { raw, err := os.ReadFile(payloadFile) if err != nil { @@ -61,7 +68,7 @@ func (a *App) debugStepsCmd() *cobra.Command { return fmt.Errorf("bad payload JSON: %w", err) } } else { - payload = map[string]any{} + payload = json.RawMessage(`{}`) } out, err := a.Client.StepsRaw(cmd.Context(), payload) if err != nil { diff --git a/internal/cli/item.go b/internal/cli/item.go index 4c8422b..6d4206f 100644 --- a/internal/cli/item.go +++ b/internal/cli/item.go @@ -360,6 +360,9 @@ func (a *App) itemEditCmd() *cobra.Command { if !fl.Changed("title") && !fl.Changed("description") && !fl.Changed("price") && !fl.Changed("condition") && !fl.Changed("category") && len(attrs) == 0 && len(images) == 0 { return output.Usagef("nothing to change. Pass at least one of --title, --description, --price, --condition, --category, --attr, --image") } + if fl.Changed("category") && categoryID <= 0 { + return output.Usagef("--category must be a positive leaf category id") + } hash, err := a.ownedItemHash(cmd.Context(), args[0]) if err != nil { return err @@ -393,8 +396,12 @@ func (a *App) itemEditCmd() *cobra.Command { if err != nil { return err } - cat, err := wallapop.ResolveCreateCategory(cats, it.Category) - if err != nil && it.CategoryID != 0 { + category := it.Category + if fl.Changed("category") { + category = strconv.Itoa(categoryID) + } + cat, err := wallapop.ResolveCreateCategory(cats, category) + if err != nil && !fl.Changed("category") && it.CategoryID != 0 { // Fall back to the numeric id when the name no longer resolves. cat, err = wallapop.ResolveCreateCategory(cats, strconv.Itoa(it.CategoryID)) } diff --git a/internal/fakewallapop/fake.go b/internal/fakewallapop/fake.go index 4ded7ee..05d95d2 100644 --- a/internal/fakewallapop/fake.go +++ b/internal/fakewallapop/fake.go @@ -114,6 +114,7 @@ type Server struct { BlockNextAPI bool // next api.wallapop.com request gets a CloudFront 403 MintEmpty bool // /api/auth/session answers {} (invalid session) MalformedItem bool // /api/v3/items/{hash} answers non-JSON + PictureStatus int // nonzero rejects picture2 uploads CreateCalls int // NewItemPageLags makes a created listing's rendered page 404 while its // API detail already answers, the propagation window Wallapop really has. @@ -591,6 +592,10 @@ func (s *Server) items(w http.ResponseWriter, r *http.Request) { s.mu.Unlock() w.WriteHeader(204) case action == "picture2" && r.Method == http.MethodPost: + if s.PictureStatus != 0 { + writeJSON(w, s.PictureStatus, map[string]any{"message": "picture rejected"}) + return + } // Mirrors the web's upload queue: multipart with one `image` part and // a plain `order` field (1-based gallery position). Appends the image. if !s.authed(w, r) || !s.owned(w, it, 403) { diff --git a/internal/wallapop/publish.go b/internal/wallapop/publish.go index 478e2da..8cfb55c 100644 --- a/internal/wallapop/publish.go +++ b/internal/wallapop/publish.go @@ -136,13 +136,11 @@ func (c *Client) CreateItem(ctx context.Context, in CreateInput) (Item, error) { // Gallery order: the create consumed position 0, the rest go to // picture2 with their 1-based positions. A failure here leaves a live // listing missing pictures, so report it but keep the item. - var picErr error for i, img := range imagesFrom(in.Images, 1) { if err := c.uploadItemPicture(ctx, out.ID, i+1, img); err != nil { if c.Notice != nil { c.Notice(fmt.Sprintf("published %s, but picture %d failed to upload: %v", out.ID, i+2, err)) } - picErr = err } } // The listing exists from here on. A fresh hash can take a moment to be @@ -153,13 +151,13 @@ func (c *Client) CreateItem(ctx context.Context, in CreateInput) (Item, error) { if c.Notice != nil { c.Notice("published " + out.ID + ", but reading it back failed: " + err.Error()) } - return in.published(out.ID), picErr + return in.published(out.ID), nil } // A page 404 makes Client.Item infer "expired", which is right for a // listing Wallapop has hidden and wrong for one published seconds ago: // there the page simply has not propagated yet. it.Expired = false - return it, picErr + return it, nil } // published describes a listing straight from what was sent, for the window @@ -255,13 +253,11 @@ func (c *Client) EditItem(ctx context.Context, hash, ownerHash string, in EditIn } // Like create: the write replaces the gallery with the first image, the // rest go through picture2 in gallery order. - var picErr error for i, img := range imagesFrom(in.Images, 1) { if err := c.uploadItemPicture(ctx, current.Hash, i+1, img); err != nil { if c.Notice != nil { c.Notice(fmt.Sprintf("edited %s, but picture %d failed to upload: %v", current.Hash, i+2, err)) } - picErr = err } } // The edit has landed. Like a create, failing the command because the @@ -280,9 +276,9 @@ func (c *Client) EditItem(ctx context.Context, hash, ownerHash string, in EditIn // them would be worse than reporting none. current.Images = nil } - return current, picErr + return current, nil } - return it, picErr + return it, nil } // editLeafID recovers the listing's leaf category for the write payload.