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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion internal/api/media.go
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions internal/api/media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
51 changes: 38 additions & 13 deletions internal/cli/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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')")
Expand Down Expand Up @@ -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
Expand All @@ -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)")
Expand Down
76 changes: 66 additions & 10 deletions internal/cli/codes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading