Skip to content
Open
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
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ kronan logout Clear stored token
kronan search <query> Search for products
kronan product <sku> Product details by SKU

kronan cart View cart
kronan cart add <sku> [qty] Add item to cart
kronan cart clear Clear cart
kronan cart View cart
kronan cart add <sku> [qty] Add item to cart
kronan cart set '<json>' Replace whole cart with JSON lines
kronan cart update <sku|id> --quantity N Update one line (qty 0 removes)
kronan cart update '<json-patch>' Bulk update via JSON patch
kronan cart remove <sku|id> Remove a single line
kronan cart clear Clear cart
(set/update/remove/clear support --dry-run)

kronan orders Order history
kronan order <token> Specific order details
Expand All @@ -85,7 +90,32 @@ kronan lists View product lists
kronan me Show current identity
```

All commands support `--json` for structured output.
All commands support `--json` for structured output. Cart mutation commands
(`set`, `update`, `remove`, `clear`) also accept `--dry-run` to preview the
resulting cart without calling the API.

### Cart editing examples

```bash
# Replace the entire cart with a fixed set of lines
kronan cart set '[{"sku":"02500188","quantity":3}]'
kronan cart set '{"02500188":1,"100151784":2}'

# Update a single line by SKU or numeric line id
kronan cart update 02500188 --quantity 5
kronan cart update 13065307 --quantity 0 # qty 0 removes the line

# Bulk update: object/array of {sku:qty} as a patch.
# Listed SKUs are set to the given qty (0 removes); unlisted lines are left
# alone. SKUs not currently in the cart are added when qty > 0.
kronan cart update '{"02500188":3,"100151784":0,"100224198":1}'

# Remove a single line (shorthand for --quantity 0)
kronan cart remove 02500188

# Preview any of the above without hitting the API
kronan cart update '{"02500188":3}' --dry-run --json
```

## AI agent usage

Expand Down
35 changes: 34 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@ export async function addCheckoutLines(

/**
* Replace all checkout lines.
*
* Workaround for upstream API behaviour: `POST /checkout/lines/` with
* `{lines: [], replace: true}` is silently a no-op (returns the unchanged
* checkout instead of clearing it — see arnif/kronan-cli#7). When the caller
* asks for an empty cart we instead fetch the current lines and send each one
* back with `quantity: 0`, which the backend honours.
*/
export async function replaceCheckoutLines(
token: AuthToken,
Expand All @@ -553,7 +559,34 @@ export async function replaceCheckoutLines(
substitution?: boolean;
}>,
): Promise<PublicCheckout> {
return addCheckoutLines(token, lines, true);
if (lines.length > 0) {
return addCheckoutLines(token, lines, true);
}

const checkout = await getCheckout(token);
const existing = checkout?.lines ?? [];
if (existing.length === 0) {
// Cart already empty; nothing to do. Synthesize an empty checkout if the
// GET returned null (no active checkout).
return (
checkout ?? {
token: "",
lines: [],
total: 0,
subtotal: 0,
baggingFee: 0,
serviceFee: 0,
shippingFee: 0,
shippingFeeCutoff: 0,
}
);
}
const deletes = existing.map((line) => ({
sku: line.product?.sku ?? "",
quantity: 0,
substitution: line.substitution ?? true,
}));
return addCheckoutLines(token, deletes, true);
}

// --- Product Lists API Functions ---
Expand Down
30 changes: 30 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@ describe("CLI", () => {
expect(result).toContain("token");
});

test("help lists cart set/update/remove commands", async () => {
const result = await $`bun src/index.ts help`.text();
expect(result).toContain("cart set");
expect(result).toContain("cart update");
expect(result).toContain("cart remove");
expect(result).toContain("--dry-run");
});

test("cart set without JSON argument errors", async () => {
const proc = Bun.spawn(["bun", "src/index.ts", "cart", "set"], {
stderr: "pipe",
});
const stderr = await new Response(proc.stderr).text();
expect(stderr).toContain("Usage: kronan cart set");
});

test("cart update without --quantity errors", async () => {
const proc = Bun.spawn(
["bun", "src/index.ts", "cart", "update", "02500188"],
{ stderr: "pipe" },
);
const stderr = await new Response(proc.stderr).text();
expect(stderr).toContain("--quantity");
});

test("help documents bulk cart update JSON form", async () => {
const result = await $`bun src/index.ts help`.text();
expect(result).toContain("json-patch");
});

test("no arguments shows help", async () => {
const result = await $`bun src/index.ts`.text();
expect(result).toContain("kronan-cli");
Expand Down
74 changes: 74 additions & 0 deletions src/commands/cart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { parseCartLinesJson } from "./cart.ts";

describe("parseCartLinesJson", () => {
test("parses array of {sku, quantity}", () => {
const lines = parseCartLinesJson(
'[{"sku":"02500059","quantity":1},{"sku":"100151784","quantity":2}]',
);
expect(lines).toEqual([
{ sku: "02500059", quantity: 1 },
{ sku: "100151784", quantity: 2 },
]);
});

test("preserves substitution when present in array form", () => {
const lines = parseCartLinesJson(
'[{"sku":"02500059","quantity":1,"substitution":false}]',
);
expect(lines).toEqual([
{ sku: "02500059", quantity: 1, substitution: false },
]);
});

test("parses object map {sku:quantity}", () => {
const lines = parseCartLinesJson('{"02500059":1,"100151784":2}');
expect(lines.length).toBe(2);
expect(lines).toEqual(
expect.arrayContaining([
{ sku: "02500059", quantity: 1 },
{ sku: "100151784", quantity: 2 },
]),
);
});

test("rejects invalid JSON", () => {
expect(() => parseCartLinesJson("not json")).toThrow(/Invalid JSON/);
});

test("rejects negative quantities in array form", () => {
expect(() =>
parseCartLinesJson('[{"sku":"02500059","quantity":-1}]'),
).toThrow(/non-negative integer/);
});

test("rejects negative quantities in map form", () => {
expect(() => parseCartLinesJson('{"02500059":-1}')).toThrow(
/non-negative integer/,
);
});

test("rejects non-integer quantities", () => {
expect(() =>
parseCartLinesJson('[{"sku":"02500059","quantity":1.5}]'),
).toThrow(/non-negative integer/);
});

test("rejects missing sku", () => {
expect(() => parseCartLinesJson('[{"quantity":1}]')).toThrow(
/sku must be a non-empty string/,
);
});

test("rejects non-number map value", () => {
expect(() => parseCartLinesJson('{"02500059":"1"}')).toThrow(
/must be a number/,
);
});

test("rejects scalar JSON", () => {
expect(() => parseCartLinesJson("42")).toThrow(
/must be an array.*or an object map/,
);
});
});
Loading
Loading