diff --git a/CHANGELOG.md b/CHANGELOG.md index faacb93..c2c78c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `stqry quizzes` — full CRUD for the Quizzes Public API across the quiz → questions → answers hierarchy, with matching MCP tools, shell completion, and reference/coverage docs. Translated fields respect `--lang`, `--question-type` is validated client-side, answers carry a `--correct` flag, and `delete`/`remove` accept `--lang` for per-locale translation deletes. +### Fixed + +- Aligned CLI flag types, values and validation with what the public API actually accepts: `codes --max-redemptions 0` / `--expire-after 0` now send `null` (unlimited / clear) rather than 422; `maps paths --cost` is an integer and `--cost-override` a boolean; `maps paths --direction` accepts `bidirectional | forward | backward`; `maps layers features create` requires `--name`; `collections items --radius` is guarded `>= 1`; `screens sections prices`/`hours` honour `--lang` for their translated fields; `CrossRegionLink` is rejected client-side for collection-item and link-item types; media types validate through one shared `api.ValidateMediaType`; and `codes list --sort-field` no longer advertises the unsupported `redemptions`. + ## [0.10.33] - 2026-05-28 ### Added diff --git a/internal/api/media.go b/internal/api/media.go index 28cc233..a188b58 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -1,6 +1,9 @@ package api -import "fmt" +import ( + "fmt" + "strings" +) // ValidMediaTypes lists the allowed media item subtypes. Mirrors // MediaItem::MEDIA_ITEM_SUBTYPES_SHORT in mytours-web (app/models/media_item.rb). @@ -10,6 +13,21 @@ var ValidMediaTypes = []string{ "image", "video", "webvideo", "ar", "data", } +// ValidateMediaType returns nil if t is an allowed media item subtype, else an +// error naming the canonical list. Lives here (next to ValidMediaTypes, the +// single source of truth) so the CLI and the MCP server validate identically — +// they previously disagreed both in wording ("(valid: …)" vs "must be one of …") +// and in how the list was sourced (the MCP copy was hand-typed and could drift +// from ValidMediaTypes). Mirrors the ValidateLanguage convention. +func ValidateMediaType(t string) error { + for _, v := range ValidMediaTypes { + if t == v { + return nil + } + } + return fmt.Errorf("invalid media type %q (valid: %s)", t, strings.Join(ValidMediaTypes, ", ")) +} + // ListMediaItems returns a paginated list of media items. func ListMediaItems(c *Client, query map[string]string) ([]map[string]interface{}, *PaginationMeta, error) { var resp struct { diff --git a/internal/api/media_test.go b/internal/api/media_test.go index 29801cc..899c2ee 100644 --- a/internal/api/media_test.go +++ b/internal/api/media_test.go @@ -4,9 +4,33 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) +// TestValidateMediaType covers the shared validator that both the CLI and the +// MCP server route through (so their errors and accepted sets can't drift). +func TestValidateMediaType(t *testing.T) { + for _, valid := range ValidMediaTypes { + if err := ValidateMediaType(valid); err != nil { + t.Errorf("ValidateMediaType(%q) = %v, want nil", valid, err) + } + } + err := ValidateMediaType("nope") + if err == nil { + t.Fatal("ValidateMediaType(\"nope\") = nil, want error") + } + if !strings.Contains(err.Error(), "invalid media type") { + t.Errorf("error should mention \"invalid media type\", got %q", err.Error()) + } + // The error must list the canonical set so callers can self-correct. + for _, want := range []string{"image", "audio", "video"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should list valid type %q, got %q", want, err.Error()) + } + } +} + func TestGetMediaItem(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { diff --git a/internal/cli/codes.go b/internal/cli/codes.go index f3e2bf9..da835b2 100644 --- a/internal/cli/codes.go +++ b/internal/cli/codes.go @@ -10,6 +10,17 @@ import ( "github.com/spf13/cobra" ) +// nilIfZero maps the CLI's "0 = unlimited" max-redemptions sentinel to the +// JSON null the public API expects (the Code model validates +// max_redemptions >= 1, allow_nil — a literal 0 is rejected). Any positive +// value passes through unchanged. +func nilIfZero(n int) interface{} { + if n == 0 { + return nil + } + return n +} + func newCodesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "codes", @@ -109,7 +120,11 @@ func newCodesListCmd() *cobra.Command { cmd.Flags().IntVar(&perPage, "per-page", 0, "Results per page") cmd.Flags().StringVar(&q, "q", "", "Search query (matches coupon_code)") cmd.Flags().StringVar(&tags, "tags", "", "Filter by tags (comma-separated; tags are ANDed — '--tags press,launch' matches codes tagged with BOTH)") - cmd.Flags().StringVar(&sortField, "sort-field", "", "Field to sort by (one of: id, coupon_code, valid_from, valid_to, redemptions)") + // The public API's codes#index sorts by id, coupon_code, valid_from, or + // valid_to only (else it falls back to id). The Code model's SORT_FIELDS + // constant also lists "redemptions", but the public controller does not + // implement it — advertising it here would silently sort by id instead. + cmd.Flags().StringVar(&sortField, "sort-field", "", "Field to sort by (one of: id, coupon_code, valid_from, valid_to)") cmd.Flags().StringVar(&sortDirection, "sort-direction", "", "Sort direction (asc / desc)") cmd.Flags().BoolVar(&includeArchived, "include-archived", false, "Include archived codes (default omits them)") cmd.Flags().BoolVar(&validNow, "valid-now", false, "Only codes currently in their valid_from / valid_to window") @@ -187,17 +202,22 @@ func newCodesCreateCmd() *cobra.Command { if validTo != "" { fields["valid_to"] = validTo } - // `--max-redemptions 0` is a documented "unlimited" sentinel - // on the server side. Distinguish "user passed 0 explicitly" - // from "user didn't pass the flag" the same way `codes update` - // does (via Changed) so the two paths agree — and so the - // explicit-0 case lands in the body instead of being silently - // dropped by a `> 0` filter. + // `--max-redemptions 0` is the user-facing "unlimited" sentinel, + // but the public API represents unlimited as null, not 0: the + // Code model validates `max_redemptions >= 1, allow_nil`, so a + // literal 0 is rejected (422 "Max redemptions must be greater + // than or equal to 1"). (The 0→nil coercion only exists on the + // CSV-import path, not this endpoint.) Translate the sentinel to + // null so the documented behaviour actually yields an unlimited + // code; pass through any positive value verbatim. if cmd.Flags().Changed("max-redemptions") { - fields["max_redemptions"] = maxRedemptions + fields["max_redemptions"] = nilIfZero(maxRedemptions) } if cmd.Flags().Changed("expire-after") { - fields["expire_after"] = expireAfter + // --expire-after 0 is the documented "clear" sentinel; the server + // validates expire_after >= 1, allow_nil, so a literal 0 422s. + // Send null (clears any existing expiry); positives pass through. + fields["expire_after"] = nilIfZero(expireAfter) } if timezone != "" { fields["timezone"] = timezone @@ -220,7 +240,7 @@ func newCodesCreateCmd() *cobra.Command { cmd.Flags().IntVar(&projectID, "project-id", 0, "Project ID (required)") cmd.Flags().StringVar(&validFrom, "valid-from", "", "Valid from date") cmd.Flags().StringVar(&validTo, "valid-to", "", "Valid to date") - cmd.Flags().IntVar(&maxRedemptions, "max-redemptions", 0, "Maximum number of redemptions") + cmd.Flags().IntVar(&maxRedemptions, "max-redemptions", 0, "Maximum number of redemptions; 0 means unlimited (sent to the API as null — the server requires >= 1 otherwise)") cmd.Flags().IntVar(&expireAfter, "expire-after", 0, "Auto-expire N days after the first redemption (server-validated; pass 0 to clear)") cmd.Flags().StringVar(&timezone, "timezone", "", "Timezone for --valid-from / --valid-to evaluation (e.g. America/New_York)") cmd.Flags().StringVar(&tags, "tags", "", "Comma-separated tag list (e.g. 'press,launch-2026')") @@ -257,10 +277,15 @@ func newCodesUpdateCmd() *cobra.Command { fields["valid_to"] = validTo } if cmd.Flags().Changed("max-redemptions") { - fields["max_redemptions"] = maxRedemptions + // 0 = unlimited → null (see codes create; the server rejects a + // literal 0). On update this also clears an existing limit. + fields["max_redemptions"] = nilIfZero(maxRedemptions) } if cmd.Flags().Changed("expire-after") { - fields["expire_after"] = expireAfter + // --expire-after 0 is the documented "clear" sentinel; the server + // validates expire_after >= 1, allow_nil, so a literal 0 422s. + // Send null (clears any existing expiry); positives pass through. + fields["expire_after"] = nilIfZero(expireAfter) } if cmd.Flags().Changed("timezone") { fields["timezone"] = timezone @@ -284,7 +309,7 @@ func newCodesUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&couponCode, "coupon-code", "", "Coupon code value") cmd.Flags().StringVar(&validFrom, "valid-from", "", "Valid from date") cmd.Flags().StringVar(&validTo, "valid-to", "", "Valid to date") - cmd.Flags().IntVar(&maxRedemptions, "max-redemptions", 0, "Maximum number of redemptions") + cmd.Flags().IntVar(&maxRedemptions, "max-redemptions", 0, "Maximum number of redemptions; 0 means unlimited (sent to the API as null — the server requires >= 1 otherwise)") cmd.Flags().IntVar(&expireAfter, "expire-after", 0, "Auto-expire N days after the first redemption (server-validated; pass 0 to clear)") cmd.Flags().StringVar(&timezone, "timezone", "", "Timezone for --valid-from / --valid-to evaluation (e.g. America/New_York; pass empty to clear)") cmd.Flags().StringVar(&tags, "tags", "", "Comma-separated tag list (replaces the existing tags; pass empty to clear)") diff --git a/internal/cli/codes_test.go b/internal/cli/codes_test.go index 12bff83..de0cb2a 100644 --- a/internal/cli/codes_test.go +++ b/internal/cli/codes_test.go @@ -303,13 +303,12 @@ func TestCodesCreateCmd(t *testing.T) { } } -// TestCodesCreateCmdMaxRedemptionsZero pins the parity with `codes update`: -// `--max-redemptions 0` is a documented "unlimited" sentinel on the -// server, so the create path must distinguish "user passed 0 -// explicitly" from "user didn't pass the flag" via cmd.Flags().Changed. -// Before this fix the create path used a `> 0` filter that silently -// dropped an explicit 0 and the update path's Changed()-based path -// kept it — two paths, two behaviours for the same flag value. +// TestCodesCreateCmdMaxRedemptionsZero pins the "0 = unlimited" sentinel +// translation: the user passes --max-redemptions=0 to mean unlimited, but the +// public API rejects a literal 0 (the Code model validates +// max_redemptions >= 1, allow_nil) — unlimited is represented as null. The +// create path must therefore send max_redemptions: null (present in the body, +// so it's distinguished from "flag not passed", but null rather than 0). func TestCodesCreateCmdMaxRedemptionsZero(t *testing.T) { var captured map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -330,10 +329,10 @@ func TestCodesCreateCmdMaxRedemptionsZero(t *testing.T) { } v, present := captured["max_redemptions"] if !present { - t.Fatalf("expected max_redemptions in body when user passed --max-redemptions=0 explicitly, got absent") + t.Fatalf("expected max_redemptions key present in body for explicit --max-redemptions=0, got absent") } - if f, _ := v.(float64); f != 0 { - t.Errorf("expected max_redemptions=0, got %v", v) + if v != nil { + t.Errorf("expected max_redemptions=null (unlimited; the server rejects a literal 0), got %v", v) } } @@ -401,6 +400,63 @@ func TestCodesUpdateCmd(t *testing.T) { } } +// TestCodesUpdateCmdMaxRedemptionsZero mirrors the create-path test: on update, +// --max-redemptions 0 (the "unlimited" sentinel) must be sent as null, not a +// literal 0 the server's `>= 1` validation would reject. On update this also +// clears any existing limit. +func TestCodesUpdateCmdMaxRedemptionsZero(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&captured) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"code": map[string]interface{}{"id": "10"}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"codes", "update", "10", "--max-redemptions=0"}) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + v, present := captured["max_redemptions"] + if !present { + t.Fatalf("expected max_redemptions key present for explicit --max-redemptions=0, got absent") + } + if v != nil { + t.Errorf("expected max_redemptions=null (unlimited), got %v", v) + } +} + +// TestCodesUpdateCmdExpireAfterZero asserts the documented "--expire-after 0 +// clears the expiry" sentinel is sent as null (the server validates +// expire_after >= 1, allow_nil — a literal 0 would 422). +func TestCodesUpdateCmdExpireAfterZero(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&captured) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"code": map[string]interface{}{"id": "10"}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{"codes", "update", "10", "--expire-after=0"}) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + v, present := captured["expire_after"] + if !present { + t.Fatalf("expected expire_after key present for explicit --expire-after=0, got absent") + } + if v != nil { + t.Errorf("expected expire_after=null (cleared), got %v", v) + } +} + // TestCodesDeleteCmd asserts that `stqry codes delete 10` sends a DELETE // request to the correct endpoint and prints a confirmation. func TestCodesDeleteCmd(t *testing.T) { diff --git a/internal/cli/collections.go b/internal/cli/collections.go index bb5f5a0..160d827 100644 --- a/internal/cli/collections.go +++ b/internal/cli/collections.go @@ -46,12 +46,14 @@ var validTourTypes = []string{ // docs/public_api.json. var validGeofenceModes = []string{"off", "gps", "beacon"} -// validCollectionItemTypes mirrors CollectionItemPartial.item_type in -// docs/public_api.json. Note this is wider than the project-tab item-type -// enum (which is just Collection | Screen) — collection items can also point -// at CollectionItem (a nested reference) and CrossRegionLink (a pointer to -// content in another region). -var validCollectionItemTypes = []string{"Collection", "Screen", "CollectionItem", "CrossRegionLink"} +// validCollectionItemTypes is the set of collection-item types the public API +// actually accepts. The OpenAPI spec's CollectionItemPartial.item_type enum in +// docs/public_api.json is wider — it also lists "CrossRegionLink" — but the API +// rejects that value with a 422, so its inclusion in the spec is stale (same +// intentional exclusion as link items). +// This is wider than the project-tab item-type enum (just Collection | Screen) +// — collection items can also point at CollectionItem (a nested reference). +var validCollectionItemTypes = []string{"Collection", "Screen", "CollectionItem"} func validateCollectionType(t string) error { for _, v := range validCollectionTypes { @@ -641,11 +643,17 @@ func newCollectionsItemsAddCmd() *cobra.Command { if err := validateMapPinColour(mapPinColour); err != nil { return err } - // item_type is enumerated server-side as Collection / Screen / - // CollectionItem / CrossRegionLink (per docs/public_api.json - // CollectionItemPartial). Validate client-side so a typo or - // the wrong vocabulary (e.g. "Story" instead of "Screen") - // fails fast with the valid set. + // gps_settings.radius is validated server-side as >= 1 metre + // (GpsSettings model). Catch a bad value client-side so it fails + // fast instead of a 422 on the nested settings. + if cmd.Flags().Changed("radius") && radius < 1 { + return fmt.Errorf("--radius must be >= 1 (metres); got %d", radius) + } + // item_type is enumerated by the public API as Collection / + // Screen / CollectionItem (see validCollectionItemTypes for why + // CrossRegionLink is excluded). Validate client-side so a typo or + // the wrong + // vocabulary (e.g. "Story" instead of "Screen") fails fast. if err := validateEnum("item-type", itemType, validCollectionItemTypes); err != nil { return err } @@ -699,14 +707,14 @@ func newCollectionsItemsAddCmd() *cobra.Command { cmd.Flags().StringVar(&itemType, "item-type", "", fmt.Sprintf("Item type (required; one of: %s)", strings.Join(validCollectionItemTypes, ", "))) cmd.Flags().StringVar(&itemID, "item-id", "", "Item ID (required)") cmd.Flags().IntVar(&position, "position", 0, "Position in the collection (0-based; omit to append to the end). The backend stores top_of_list: 0; values past the end are clamped to the end. Sparse inserts (e.g. position 4 then 2 then 1) preserve relative order but can leave gaps in the integer positions — renumber yourself if you need a contiguous 0..N sequence.") - cmd.Flags().StringVar(&itemNumber, "item-number", "", "Display number shown in list / map views") + cmd.Flags().StringVar(&itemNumber, "item-number", "", "Display number shown in list / map views (non-negative integer; server validates item_number >= 0)") cmd.Flags().Float64Var(&lat, "lat", 0, "Latitude for the map pin") cmd.Flags().Float64Var(&lng, "lng", 0, "Longitude for the map pin") cmd.Flags().StringVar(&mapPinIcon, "map-pin-icon", "", "Map pin icon name. \"default\" resets to the tour default.") cmd.Flags().StringVar(&mapPinStyle, "map-pin-style", "", "Map pin style name. \"default\" resets to the tour default.") cmd.Flags().StringVar(&mapPinColour, "map-pin-colour", "", "Map pin colour as a CSS hex code (with or without leading #, e.g. \"#FF6600\" or \"FF6600\"), or \"default\" to reset. Validated client-side.") cmd.Flags().StringVar(&geofence, "geofence", "", fmt.Sprintf("Geofence mode (one of: %s). Without also setting --radius and --geofence-lat / --geofence-lng, switching mode alone does nothing.", strings.Join(validGeofenceModes, ", "))) - cmd.Flags().IntVar(&radius, "radius", 0, "Geofence radius in meters (writes gps_settings.radius)") + cmd.Flags().IntVar(&radius, "radius", 0, "Geofence radius in metres, >= 1 (writes gps_settings.radius; the server rejects < 1)") cmd.Flags().Float64Var(&geofenceLat, "geofence-lat", 0, "Geofence centre latitude (gps_settings.geofence_lat). Independent of --lat — pass both when you want them equal.") cmd.Flags().Float64Var(&geofenceLng, "geofence-lng", 0, "Geofence centre longitude (gps_settings.geofence_lng). Independent of --lng — pass both when you want them equal.") cmd.Flags().BoolVar(&geofenceContent, "geofence-content", false, "Whether entering the geofence triggers content (gps_settings.geofence_content)") @@ -739,7 +747,7 @@ func newCollectionsItemsUpdateCmd() *cobra.Command { stqry collections items update 42 99 --geofence gps --radius 50 # Set a custom item number (e.g. "1A") shown in some UIs - stqry collections items update 42 99 --item-number "1A" + stqry collections items update 42 99 --item-number 5 # Change the map pin colour (CSS hex, with or without leading #) stqry collections items update 42 99 --map-pin-colour "#FF6600" @@ -754,6 +762,11 @@ func newCollectionsItemsUpdateCmd() *cobra.Command { if err := validateMapPinColour(mapPinColour); err != nil { return err } + // gps_settings.radius is validated server-side as >= 1 metre; catch + // it client-side (same as add) so it fails fast, not as a 422. + if cmd.Flags().Changed("radius") && radius < 1 { + return fmt.Errorf("--radius must be >= 1 (metres); got %d", radius) + } fields := map[string]interface{}{} gpsUpdates := map[string]interface{}{} cmd.Flags().Visit(func(f *flag.Flag) { @@ -807,14 +820,14 @@ func newCollectionsItemsUpdateCmd() *cobra.Command { } cmd.Flags().IntVar(&position, "position", 0, "Position in the collection (0-based — the backend stores top_of_list: 0). Pass --position 0 to move to the top; values past the end are clamped server-side, but sparse writes can leave gaps (renumber yourself if you need a contiguous 0..N sequence).") - cmd.Flags().StringVar(&itemNumber, "item-number", "", "Display number shown in list / map views") + cmd.Flags().StringVar(&itemNumber, "item-number", "", "Display number shown in list / map views (non-negative integer; server validates item_number >= 0)") cmd.Flags().Float64Var(&lat, "lat", 0, "Latitude for the map pin") cmd.Flags().Float64Var(&lng, "lng", 0, "Longitude for the map pin") cmd.Flags().StringVar(&mapPinIcon, "map-pin-icon", "", "Map pin icon name. \"default\" resets to the tour default.") cmd.Flags().StringVar(&mapPinStyle, "map-pin-style", "", "Map pin style name. \"default\" resets to the tour default.") cmd.Flags().StringVar(&mapPinColour, "map-pin-colour", "", "Map pin colour as a CSS hex code (with or without leading #, e.g. \"#FF6600\" or \"FF6600\"), or \"default\" to reset. Validated client-side.") cmd.Flags().StringVar(&geofence, "geofence", "", fmt.Sprintf("Geofence mode (one of: %s). Without also setting --radius and ensuring lat/lng exist, switching mode alone does nothing.", strings.Join(validGeofenceModes, ", "))) - cmd.Flags().IntVar(&radius, "radius", 0, "Geofence radius in meters (writes gps_settings.radius)") + cmd.Flags().IntVar(&radius, "radius", 0, "Geofence radius in metres, >= 1 (writes gps_settings.radius; the server rejects < 1)") cmd.Flags().Float64Var(&geofenceLat, "geofence-lat", 0, "Geofence centre latitude (gps_settings.geofence_lat); falls back to --lat if null") cmd.Flags().Float64Var(&geofenceLng, "geofence-lng", 0, "Geofence centre longitude (gps_settings.geofence_lng); falls back to --lng if null") cmd.Flags().BoolVar(&geofenceContent, "geofence-content", false, "Whether entering the geofence triggers content (gps_settings.geofence_content)") diff --git a/internal/cli/collections_test.go b/internal/cli/collections_test.go index bde3053..b75faab 100644 --- a/internal/cli/collections_test.go +++ b/internal/cli/collections_test.go @@ -765,6 +765,51 @@ func TestCollectionsCreateCmdInvalidTourType(t *testing.T) { } } +// TestCollectionsItemsAddRejectsCrossRegionLink locks in the contradiction fix: +// docs/public_api.json lists CrossRegionLink in CollectionItemPartial.item_type, +// but the public API rejects that value with a 422. The CLI must reject it +// client-side so it never slips back in from the stale spec. +func TestCollectionsItemsAddRejectsCrossRegionLink(t *testing.T) { + setupTestHome(t, "http://localhost:0") + + cmd := newRootCmd() + cmd.SetArgs([]string{"collections", "items", "add", "42", + "--item-type=CrossRegionLink", "--item-id=99"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + if err == nil { + t.Fatal("expected client-side error for item-type CrossRegionLink, got nil") + } + if !contains(err.Error(), "invalid item-type") { + t.Errorf("expected error to mention \"invalid item-type\", got %q", err.Error()) + } + // The valid list must include CollectionItem but stop at it — the three + // public-API types only, no CrossRegionLink. + if !contains(err.Error(), "valid: Collection, Screen, CollectionItem") { + t.Errorf("expected valid list to be exactly Collection, Screen, CollectionItem, got %q", err.Error()) + } +} + +// TestCollectionsItemsUpdateRadiusBelowOne asserts --radius 0 is rejected +// client-side: gps_settings.radius is validated server-side as >= 1, so the +// CLI catches a sub-1 value before the request instead of letting it 422. +func TestCollectionsItemsUpdateRadiusBelowOne(t *testing.T) { + setupTestHome(t, "http://localhost:0") + + cmd := newRootCmd() + cmd.SetArgs([]string{"collections", "items", "update", "42", "99", "--radius=0"}) + cmd.SetOut(os.Stderr) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + if err == nil { + t.Fatal("expected client-side error for --radius=0, got nil") + } + if !contains(err.Error(), "--radius must be >= 1") { + t.Errorf("expected '--radius must be >= 1' error, got %q", err.Error()) + } +} + // TestCollectionsUpdateCmdTourType asserts --tour-type on update PATCHes the // flat string field. func TestCollectionsUpdateCmdTourType(t *testing.T) { @@ -1064,7 +1109,7 @@ func TestCollectionsItemsAddCmdMapPinFields(t *testing.T) { cmd.SetArgs([]string{"collections", "items", "add", "42", "--item-type=Screen", "--item-id=99", "--map-pin-icon=marker", "--map-pin-style=default", "--map-pin-colour=#FF6600", - "--item-number=1A"}) + "--item-number=5"}) cmd.SetErr(os.Stderr) if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) @@ -1079,8 +1124,8 @@ func TestCollectionsItemsAddCmdMapPinFields(t *testing.T) { if captured["map_pin_colour"] != "#FF6600" { t.Errorf("expected map_pin_colour=#FF6600 sent verbatim, got %v", captured["map_pin_colour"]) } - if captured["item_number"] != "1A" { - t.Errorf("expected item_number=1A, got %v", captured["item_number"]) + if captured["item_number"] != "5" { + t.Errorf("expected item_number=5, got %v", captured["item_number"]) } } diff --git a/internal/cli/maps.go b/internal/cli/maps.go index 19845bc..a14af3a 100644 --- a/internal/cli/maps.go +++ b/internal/cli/maps.go @@ -25,6 +25,11 @@ var validMapPoiItemTypes = []string{"Collection", "Screen"} // / Screen / MediaItem). var validMapItemItemTypes = []string{"Collection", "CollectionItem", "Screen", "MediaItem"} +// validMapPathDirections is the set of direction values the public API accepts +// for map paths: bidirectional, forward, backward. +// Note the bidirectional value is "bidirectional", NOT "both". +var validMapPathDirections = []string{"bidirectional", "forward", "backward"} + func validateOneOf(label, value string, allowed []string) error { for _, v := range allowed { if value == v { @@ -560,14 +565,16 @@ func newMapFeaturesCreateCmd() *cobra.Command { if geojsonJSON == "" { return fmt.Errorf("--geojson-json is required") } + // MapFeature validates name presence server-side; require it + // client-side so a missing name fails fast instead of 422'ing. + if name == "" { + return fmt.Errorf("--name is required") + } geo, err := parseJSONFlag("geojson-json", geojsonJSON) if err != nil { return err } - fields := map[string]interface{}{"geojson": geo} - if name != "" { - fields["name"] = name - } + fields := map[string]interface{}{"geojson": geo, "name": name} if label != "" { fields["label"] = label } @@ -589,7 +596,7 @@ func newMapFeaturesCreateCmd() *cobra.Command { }, } scope.bind(cmd) - cmd.Flags().StringVar(&name, "name", "", "Feature name") + cmd.Flags().StringVar(&name, "name", "", "Feature name (required)") cmd.Flags().StringVar(&label, "label", "", "Feature label") cmd.Flags().StringVar(&geojsonJSON, "geojson-json", "", "GeoJSON Feature object as JSON (required)") cmd.Flags().StringVar(&settingsJSON, "settings-json", "", "Free-form settings object as JSON") @@ -1199,7 +1206,7 @@ func newMapPathsGetCmd() *cobra.Command { return cmd } -func mapPathFieldsFromFlags(cmd *cobra.Command, startNodeID, endNodeID, direction, linestringJSON string, cost, costOverride float64, accessible bool) (map[string]interface{}, error) { +func mapPathFieldsFromFlags(cmd *cobra.Command, startNodeID, endNodeID, direction, linestringJSON string, cost int, costOverride, accessible bool) (map[string]interface{}, error) { fields := map[string]interface{}{} var err error cmd.Flags().Visit(func(f *flag.Flag) { @@ -1212,12 +1219,21 @@ func mapPathFieldsFromFlags(cmd *cobra.Command, startNodeID, endNodeID, directio case "end-node-id": fields["end_node_id"] = endNodeID case "cost": + // The public API validates cost as an integer > 0 (it always + // defaults to 1 when omitted, so there's no 0/clear case). + if cost < 1 { + err = fmt.Errorf("--cost must be >= 1 (a positive integer edge weight); got %d", cost) + return + } fields["cost"] = cost case "cost-override": fields["cost_override"] = costOverride case "accessible": fields["accessible"] = accessible case "direction": + if err = validateOneOf("direction", direction, validMapPathDirections); err != nil { + return + } fields["direction"] = direction case "linestring-json": var v interface{} @@ -1232,7 +1248,8 @@ func mapPathFieldsFromFlags(cmd *cobra.Command, startNodeID, endNodeID, directio func newMapPathsCreateCmd() *cobra.Command { var mapID, startNodeID, endNodeID, direction, linestringJSON string - var cost, costOverride float64 + var cost int + var costOverride bool var accessible bool cmd := &cobra.Command{ Use: "create", @@ -1261,17 +1278,18 @@ func newMapPathsCreateCmd() *cobra.Command { cmd.Flags().StringVar(&mapID, "map-id", "", "Map ID (required)") cmd.Flags().StringVar(&startNodeID, "start-node-id", "", "Starting path-node ID (required)") cmd.Flags().StringVar(&endNodeID, "end-node-id", "", "Ending path-node ID (required)") - cmd.Flags().Float64Var(&cost, "cost", 0, "Path cost") - cmd.Flags().Float64Var(&costOverride, "cost-override", 0, "Manual cost override") + cmd.Flags().IntVar(&cost, "cost", 0, "Path cost — a positive integer edge weight. The server auto-calculates it from the path geometry unless --cost-override is set; pass --cost with --cost-override to pin a manual weight.") + cmd.Flags().BoolVar(&costOverride, "cost-override", false, "Mark the cost as manually set so the server does not auto-recalculate it from the geometry. Pair with --cost .") cmd.Flags().BoolVar(&accessible, "accessible", false, "Whether the path is wheelchair-accessible") - cmd.Flags().StringVar(&direction, "direction", "", "Direction of travel (e.g. forward, backward, both — server-validated)") + cmd.Flags().StringVar(&direction, "direction", "", "Direction of travel (one of: bidirectional (default), forward, backward)") cmd.Flags().StringVar(&linestringJSON, "linestring-json", "", "Linestring as a JSON array of [lng,lat] pairs (e.g. [[-78.87,42.90],[-78.86,42.91]])") return cmd } func newMapPathsUpdateCmd() *cobra.Command { var mapID, startNodeID, endNodeID, direction, linestringJSON string - var cost, costOverride float64 + var cost int + var costOverride bool var accessible bool cmd := &cobra.Command{ Use: "update ", @@ -1298,10 +1316,10 @@ func newMapPathsUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&mapID, "map-id", "", "Map ID (required)") cmd.Flags().StringVar(&startNodeID, "start-node-id", "", "New start node") cmd.Flags().StringVar(&endNodeID, "end-node-id", "", "New end node") - cmd.Flags().Float64Var(&cost, "cost", 0, "Path cost") - cmd.Flags().Float64Var(&costOverride, "cost-override", 0, "Manual cost override") + cmd.Flags().IntVar(&cost, "cost", 0, "Path cost — a positive integer edge weight. The server auto-calculates it from the path geometry unless --cost-override is set; pass --cost with --cost-override to pin a manual weight.") + cmd.Flags().BoolVar(&costOverride, "cost-override", false, "Mark the cost as manually set so the server does not auto-recalculate it from the geometry. Pair with --cost .") cmd.Flags().BoolVar(&accessible, "accessible", false, "Whether the path is wheelchair-accessible") - cmd.Flags().StringVar(&direction, "direction", "", "Direction of travel") + cmd.Flags().StringVar(&direction, "direction", "", "Direction of travel (one of: bidirectional, forward, backward)") cmd.Flags().StringVar(&linestringJSON, "linestring-json", "", "Replacement linestring as JSON [[lng,lat], ...]") return cmd } diff --git a/internal/cli/maps_test.go b/internal/cli/maps_test.go index cc61322..12a1131 100644 --- a/internal/cli/maps_test.go +++ b/internal/cli/maps_test.go @@ -247,6 +247,98 @@ func TestMapsPathsSplitCmd(t *testing.T) { } } +// TestMapsPathsCreateCostTypes pins the wire types for cost / cost_override: +// the server stores cost as an integer (validated only_integer, > 0) and +// cost_override as a boolean (whether the cost is manually pinned). The CLI +// must send cost as a JSON number and cost_override as a JSON bool — not the +// floats the flags used to be. +func TestMapsPathsCreateCostTypes(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/public/maps/7/paths" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&captured) + w.WriteHeader(201) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"map_path": map[string]interface{}{"id": 1}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "maps", "paths", "create", "--map-id=7", + "--start-node-id=100", "--end-node-id=200", + "--cost=5", "--cost-override", + "--linestring-json=[[-78.87,42.90],[-78.86,42.91]]", + }) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + // cost: a whole-number JSON value (decodes to float64 in Go, but must be integral). + cost, ok := captured["cost"].(float64) + if !ok || cost != 5 || cost != float64(int(cost)) { + t.Errorf("expected integer cost=5, got %v (%T)", captured["cost"], captured["cost"]) + } + // cost_override: a JSON boolean true, NOT a number. + if ov, ok := captured["cost_override"].(bool); !ok || !ov { + t.Errorf("expected cost_override=true (bool), got %v (%T)", captured["cost_override"], captured["cost_override"]) + } +} + +// TestMapsPathsCreateRejectsBothDirection pins the direction enum: the server +// value is "bidirectional", not "both", so --direction both must fail +// client-side (the CLI help used to advertise "both"). +func TestMapsPathsCreateRejectsBothDirection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s; should have errored client-side", r.Method, r.URL.Path) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "maps", "paths", "create", "--map-id=7", + "--start-node-id=100", "--end-node-id=200", + "--direction=both", + "--linestring-json=[[-78.87,42.90],[-78.86,42.91]]", + }) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --direction=both, got nil") + } + if !strings.Contains(err.Error(), "invalid direction") || !strings.Contains(err.Error(), "bidirectional") { + t.Errorf("expected 'invalid direction ... bidirectional' error, got %q", err.Error()) + } +} + +// TestMapsPathsCreateRejectsZeroCost asserts --cost 0 is rejected client-side: +// map_paths.cost is validated server-side as an integer > 0. +func TestMapsPathsCreateRejectsZeroCost(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s; should have errored client-side", r.Method, r.URL.Path) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "maps", "paths", "create", "--map-id=7", + "--start-node-id=100", "--end-node-id=200", "--cost=0", + "--linestring-json=[[-78.87,42.90],[-78.86,42.91]]", + }) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --cost=0, got nil") + } + if !strings.Contains(err.Error(), "--cost must be >= 1") { + t.Errorf("expected '--cost must be >= 1' error, got %q", err.Error()) + } +} + // TestMapRoutesCreateCmd asserts collection_id + parsed geojson are sent. func TestMapRoutesCreateCmd(t *testing.T) { var captured map[string]interface{} @@ -476,3 +568,30 @@ func TestMapPoisCreateRequiresNameOrTitle(t *testing.T) { t.Error("API should not be called when neither --name nor --title is supplied") } } + +// TestMapFeaturesCreateRequiresName asserts --name is required client-side +// (MapFeature validates name presence server-side; missing it must fail fast, +// not 422). A valid geojson is supplied so only the missing name is at fault. +func TestMapFeaturesCreateRequiresName(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer server.Close() + + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "maps", "layers", "features", "create", + "--map-id", "7", "--layer-id", "99", + `--geojson-json={"type":"Feature","geometry":{"type":"Point","coordinates":[-78.87,42.90]},"properties":{}}`, + }) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when --name is missing") + } + if called { + t.Error("API should not be called when --name is missing") + } +} diff --git a/internal/cli/media.go b/internal/cli/media.go index d191498..bd636a5 100644 --- a/internal/cli/media.go +++ b/internal/cli/media.go @@ -13,12 +13,7 @@ import ( ) func validateMediaType(t string) error { - for _, v := range api.ValidMediaTypes { - if t == v { - return nil - } - } - return fmt.Errorf("invalid media type %q (valid: %s)", t, strings.Join(api.ValidMediaTypes, ", ")) + return api.ValidateMediaType(t) } func newMediaCmd() *cobra.Command { @@ -126,7 +121,7 @@ func newMediaGetCmd() *cobra.Command { # Filter a specific field stqry media get 55 --jq '.name'`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { item, err := api.GetMediaItem(activeClient, args[0]) if err != nil { diff --git a/internal/cli/screens.go b/internal/cli/screens.go index ee5b44e..46d184c 100644 --- a/internal/cli/screens.go +++ b/internal/cli/screens.go @@ -33,10 +33,14 @@ var validLinkTypes = []string{ // validIconTypes mirrors StorySectionLinkItemPartial.icon_type. var validIconTypes = []string{"media_item", "stock_icon", "clear"} -// validLinkItemTypes mirrors StorySectionLinkItemPartial.item_type (the -// linked-resource type for `link_type: internal` links). +// validLinkItemTypes is the linked-resource type for `link_type: internal` +// links — the set the public API actually accepts. The OpenAPI spec's wider +// StorySectionLinkItemPartial.item_type enum in docs/public_api.json also lists +// "CrossRegionLink", but the API rejects that value with a 422, so the spec's +// inclusion of it is stale. Validating against the spec would let a +// guaranteed-422 value through. var validLinkItemTypes = []string{ - "Bundle", "Collection", "CollectionItem", "Screen", "MediaItem", "CrossRegionLink", + "Bundle", "Collection", "CollectionItem", "Screen", "MediaItem", } // validSocialNetworks mirrors StorySectionSocialItemPartial.social_network. @@ -97,11 +101,15 @@ func validateHeaderLayout(l string) error { return validateEnum("header layout", l, validHeaderLayouts) } -func validateLinkType(t string) error { return validateEnum("link type", t, validLinkTypes) } -func validateIconType(t string) error { return validateEnum("icon type", t, validIconTypes) } -func validateLinkItemType(t string) error { return validateEnum("item type", t, validLinkItemTypes) } -func validateSocialNetwork(n string) error { return validateEnum("social network", n, validSocialNetworks) } -func validateSectionLayout(l string) error { return validateEnum("section layout", l, validSectionLayouts) } +func validateLinkType(t string) error { return validateEnum("link type", t, validLinkTypes) } +func validateIconType(t string) error { return validateEnum("icon type", t, validIconTypes) } +func validateLinkItemType(t string) error { return validateEnum("item type", t, validLinkItemTypes) } +func validateSocialNetwork(n string) error { + return validateEnum("social network", n, validSocialNetworks) +} +func validateSectionLayout(l string) error { + return validateEnum("section layout", l, validSectionLayouts) +} func validateEnum(label, value string, valid []string) error { if value == "" { @@ -230,7 +238,7 @@ func newScreensGetCmd() *cobra.Command { # Filter a specific field stqry screens get 42 --jq '.name'`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { screen, err := api.GetScreen(activeClient, args[0]) if err != nil { @@ -523,7 +531,7 @@ func newSectionsListCmd() *cobra.Command { # Pipe to external jq (alternative) stqry screens sections list 42 --quiet | jq '.[].id'`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { query := map[string]string{} if page > 0 { @@ -564,7 +572,7 @@ func newSectionsGetCmd() *cobra.Command { # Filter a specific field stqry screens sections get 99 --screen-id 42 --jq '.type'`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if screenID == "" { return fmt.Errorf("--screen-id is required") @@ -891,7 +899,7 @@ func newSectionsReorderCmd() *cobra.Command { Short: "Reorder story sections", Example: ` # Reorder sections on a screen (specify section IDs in desired order) stqry screens sections reorder 42 99 100 101`, - Args: cobra.MinimumNArgs(2), + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { screenID := args[0] sectionIDs := args[1:] @@ -1169,6 +1177,17 @@ func registerSubItemFlags(cmd *cobra.Command, cmdName string) { } } +// subItemTranslatedFields names the TranslatedString fields on the generic +// (non-links/social) sub-item types, keyed by API field name. links and social +// wrap their translated fields inline; these are the rest. buildSubItemFields +// consults this so a bare --description / --time on prices / hours is written +// under the --lang locale (like every other translated field in the CLI) +// instead of silently landing on the record's primary language. +var subItemTranslatedFields = map[string]map[string]bool{ + "prices": {"description": true}, + "hours": {"description": true, "time": true}, +} + // buildSubItemFields extracts the set flags from cmd and maps them to the // correct API field names. For fields typed as TranslatedString in the public // API the flag value is wrapped in a {lang: value} map. For fields with an @@ -1243,9 +1262,7 @@ func buildSubItemFields(cmd *cobra.Command, cmdName, lang string) (map[string]in default: // Generic path for badges / media / prices / hours: the flag name // equals the API field name once dashes become underscores. Rails - // strong-params coerces strings to ints for integer columns and - // `assign_translations` accepts a plain string for TranslatedString - // fields (assigned to the primary language). + // strong-params coerces strings to ints for integer columns. // // `position` / `start-time` are sent as int so consumers that don't // coerce strings (e.g. our own jq filters in tests) see a number @@ -1261,7 +1278,16 @@ func buildSubItemFields(cmd *cobra.Command, cmdName, lang string) (map[string]in fields["auto_play"] = v == "true" return } - fields[strings.ReplaceAll(f.Name, "-", "_")] = v + // TranslatedString fields (prices/hours description, hours time) are + // wrapped in a {lang: value} map so --lang selects the locale, just + // like links/social. A bare string would always be assigned to the + // record's primary language server-side, silently ignoring --lang. + apiField := strings.ReplaceAll(f.Name, "-", "_") + if subItemTranslatedFields[cmdName][apiField] { + fields[apiField] = map[string]interface{}{lang: v} + return + } + fields[apiField] = v } }) return fields, enumErr diff --git a/internal/cli/screens_test.go b/internal/cli/screens_test.go index 425cf14..0b7a316 100644 --- a/internal/cli/screens_test.go +++ b/internal/cli/screens_test.go @@ -877,6 +877,10 @@ func TestLinksAddCmdInvalidEnums(t *testing.T) { {"link-type", []string{"--link-type=bogus"}, "invalid link type"}, {"icon-type", []string{"--link-type=url", "--icon-type=fancy"}, "invalid icon type"}, {"item-type", []string{"--link-type=internal", "--item-type=Widget", "--item-id=1"}, "invalid item type"}, + // CrossRegionLink is in the spec's StorySectionLinkItemPartial.item_type + // enum but the public API rejects it with a 422; the CLI must reject it + // client-side too. + {"item-type-cross-region", []string{"--link-type=internal", "--item-type=CrossRegionLink", "--item-id=1"}, "invalid item type"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -1204,6 +1208,7 @@ func TestScreensSectionsAddCmdNoPosition(t *testing.T) { // previously forwarded verbatim and let the server 422 on a typo: // - text_position: left / right / top / bottom / none // - map_type: single_location / multiple_locations +// // Both are in the OpenAPI spec; before this fix the user saw something // like `API error 422: text_position is not in the list` instead of a // fast local message naming the valid set. diff --git a/internal/cli/sections_subitem_test.go b/internal/cli/sections_subitem_test.go index bf731e5..6a702b3 100644 --- a/internal/cli/sections_subitem_test.go +++ b/internal/cli/sections_subitem_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "reflect" "testing" ) @@ -94,8 +95,10 @@ func TestSectionSubItemReorderCmd(t *testing.T) { // API field names the server actually permits. Regression: the previous // generic default sent flag names verbatim (e.g. "media-item-id"), so // strong-params stripped them and the server saw no fields at all. The -// fix is purely the dash→underscore mapping — Rails handles string-to-int -// coercion and plain-string-to-TranslatedString assignment on its own. +// fix is the dash→underscore mapping (Rails coerces strings to ints on its +// own), plus wrapping TranslatedString fields (prices/hours description, +// hours time) in a {lang: value} map so --lang picks the locale instead of +// the value silently landing on the record's primary language. func TestSectionSubItemAddPayload(t *testing.T) { cases := []struct { name string @@ -139,7 +142,7 @@ func TestSectionSubItemAddPayload(t *testing.T) { want: map[string]interface{}{ "price_cents": "1500", "price_currency": "USD", - "description": "Adult", + "description": map[string]interface{}{"en": "Adult"}, }, }, { @@ -151,8 +154,8 @@ func TestSectionSubItemAddPayload(t *testing.T) { }, path: "/api/public/screens/42/story_sections/99/opening_time_items", want: map[string]interface{}{ - "description": "Mon-Fri", - "time": "9:00-17:00", + "description": map[string]interface{}{"en": "Mon-Fri"}, + "time": map[string]interface{}{"en": "9:00-17:00"}, }, }, } @@ -194,7 +197,88 @@ func TestSectionSubItemAddPayload(t *testing.T) { len(captured), len(tc.want), captured, tc.want) } for k, want := range tc.want { - if got := captured[k]; got != want { + if got := captured[k]; !reflect.DeepEqual(got, want) { + t.Errorf("key %q: got %v (%T), want %v (%T)", k, got, got, want, want) + } + } + }) + } +} + +// TestSectionSubItemAddTranslatedLang asserts that --lang selects the locale +// for the prices/hours TranslatedString fields (description, time), matching +// how links/social already behave. Before the fix these went through the +// generic path as bare strings and were silently written to the record's +// primary language, ignoring --lang. +func TestSectionSubItemAddTranslatedLang(t *testing.T) { + cases := []struct { + name string + args []string + path string + want map[string]interface{} + }{ + { + name: "prices", + args: []string{ + "screens", "sections", "prices", "add", + "--screen-id=42", "--section-id=99", + "--price-cents=1500", "--description=Adulte", "--lang=fr", + }, + path: "/api/public/screens/42/story_sections/99/price_items", + want: map[string]interface{}{ + "price_cents": "1500", + "description": map[string]interface{}{"fr": "Adulte"}, + }, + }, + { + name: "hours", + args: []string{ + "screens", "sections", "hours", "add", + "--screen-id=42", "--section-id=99", + "--description=Lun-Ven", "--time=9:00-17:00", "--lang=fr", + }, + path: "/api/public/screens/42/story_sections/99/opening_time_items", + want: map[string]interface{}{ + "description": map[string]interface{}{"fr": "Lun-Ven"}, + "time": map[string]interface{}{"fr": "9:00-17:00"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var captured map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == tc.path { + _ = json.NewDecoder(r.Body).Decode(&captured) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"item": map[string]interface{}{"id": 1}}) + return + } + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + setupTestHome(t, server.URL) + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { + w.Close() + os.Stdout = origStdout + r.Close() + }) + + cmd := newRootCmd() + cmd.SetArgs(tc.args) + cmd.SetErr(os.Stderr) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + for k, want := range tc.want { + if got := captured[k]; !reflect.DeepEqual(got, want) { t.Errorf("key %q: got %v (%T), want %v (%T)", k, got, got, want, want) } } diff --git a/internal/cli/uploaded_files.go b/internal/cli/uploaded_files.go index 4e4450b..966fb9c 100644 --- a/internal/cli/uploaded_files.go +++ b/internal/cli/uploaded_files.go @@ -147,20 +147,26 @@ instead.`, return fmt.Errorf("--filename is required") } + // The public create endpoint accepts exactly these attachment + // metadata field names: upload_file_name / upload_content_type / + // upload_file_size / upload_file_hash. They are NOT the bare + // filename / content_type / … names — sending those gets them + // dropped and the create 422s on the now-missing required fields. + // (upload_file_name is also what signals an attachment is present.) fields := map[string]interface{}{ - "filename": filename, + "upload_file_name": filename, } if contentType != "" { - fields["content_type"] = contentType + fields["upload_content_type"] = contentType } if fileHash != "" { - fields["file_hash"] = fileHash + fields["upload_file_hash"] = fileHash } if status != "" { fields["status"] = status } if cmd.Flags().Changed("file-size") { - fields["file_size"] = fileSize + fields["upload_file_size"] = fileSize } if cmd.Flags().Changed("width") { fields["width"] = width diff --git a/internal/cli/uploaded_files_test.go b/internal/cli/uploaded_files_test.go index ca2eff3..50ef990 100644 --- a/internal/cli/uploaded_files_test.go +++ b/internal/cli/uploaded_files_test.go @@ -70,17 +70,22 @@ func TestUploadedFilesCreateCmd(t *testing.T) { t.Fatalf("Execute: %v", err) } - if captured["filename"] != "photo.jpg" { - t.Errorf("expected filename=photo.jpg, got %v", captured["filename"]) - } - if captured["content_type"] != "image/jpeg" { - t.Errorf("expected content_type=image/jpeg, got %v", captured["content_type"]) - } - if captured["file_size"] != float64(12345) { - t.Errorf("expected file_size=12345, got %v", captured["file_size"]) - } - if captured["file_hash"] != "abc123" { - t.Errorf("expected file_hash=abc123, got %v", captured["file_hash"]) + // The wire fields must use the server's attachment metadata field names + // (upload_file_*), not the bare flag names — the public create endpoint + // accepts only upload_file_name / upload_content_type / upload_file_size / + // upload_file_hash, so the bare names would be dropped and the create + // would 422 on the now-missing required fields. + if captured["upload_file_name"] != "photo.jpg" { + t.Errorf("expected upload_file_name=photo.jpg, got %v", captured["upload_file_name"]) + } + if captured["upload_content_type"] != "image/jpeg" { + t.Errorf("expected upload_content_type=image/jpeg, got %v", captured["upload_content_type"]) + } + if captured["upload_file_size"] != float64(12345) { + t.Errorf("expected upload_file_size=12345, got %v", captured["upload_file_size"]) + } + if captured["upload_file_hash"] != "abc123" { + t.Errorf("expected upload_file_hash=abc123, got %v", captured["upload_file_hash"]) } }