Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs-site/content/docs/reference/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions docs/cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
104 changes: 104 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -1223,6 +1225,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)
Expand Down Expand Up @@ -1437,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)
}
}
}
102 changes: 102 additions & 0 deletions internal/cli/debug_steps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/Microck/wallapop-cli/internal/output"
"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 '<json>' 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" {
if len(args) != 2 {
return output.Usagef("debug-steps get requires one API path")
}
out, err := a.Client.StepsGetRaw(cmd.Context(), args[1])
Comment thread
Microck marked this conversation as resolved.
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "%s\n", string(out))
return nil
}
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
}
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 json.RawMessage
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 = json.RawMessage(`{}`)
}
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
}
24 changes: 20 additions & 4 deletions internal/cli/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -356,8 +357,11 @@ 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")
}
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 {
Expand All @@ -376,6 +380,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
Comment thread
Microck marked this conversation as resolved.
}
if len(attrs) > 0 {
it, err := a.Client.Item(cmd.Context(), hash)
if err != nil {
Expand All @@ -385,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))
}
Expand Down Expand Up @@ -426,6 +441,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")
Comment thread
Microck marked this conversation as resolved.
return cmd
}

Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
36 changes: 36 additions & 0 deletions internal/fakewallapop/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -590,6 +591,41 @@ 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:
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) {
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:
Expand Down
Loading
Loading