diff --git a/src/__tests__/shopping.test.ts b/src/__tests__/shopping.test.ts index 47a28b3..04abbf7 100644 --- a/src/__tests__/shopping.test.ts +++ b/src/__tests__/shopping.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { Command } from 'commander'; import { mkClient } from './mkclient.js'; import { renderRaw } from '../lib/ui.js'; @@ -46,25 +47,29 @@ let tmp: string; let prevXdg: string | undefined; let prevEmail: string | undefined; let prevPw: string | undefined; +let previousExitCode: typeof process.exitCode; beforeEach(() => { prevXdg = process.env.XDG_CONFIG_HOME; prevEmail = process.env.TAKEALOT_EMAIL; prevPw = process.env.TAKEALOT_PASSWORD; + previousExitCode = process.exitCode; tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tk-shop-')); process.env.XDG_CONFIG_HOME = tmp; process.env.TAKEALOT_EMAIL = 'shopper@example.com'; process.env.TAKEALOT_PASSWORD = 'pw'; + process.exitCode = undefined; }); afterEach(() => { process.env.XDG_CONFIG_HOME = prevXdg; process.env.TAKEALOT_EMAIL = prevEmail; process.env.TAKEALOT_PASSWORD = prevPw; + process.exitCode = previousExitCode; fs.rmSync(tmp, { recursive: true, force: true }); vi.restoreAllMocks(); }); -async function seededContext() { +async function seededContext(verbose = false) { vi.resetModules(); const cfg = await import('../lib/config.js'); cfg.saveCredentials({ @@ -82,7 +87,7 @@ async function seededContext() { device: { profile: (await import('../lib/device.js')).resolveDeviceProfile({}), did: 'DID' }, } as any); const { Context } = await import('../lib/context.js'); - return new Context({ json: true, verbose: false }); + return new Context({ json: true, verbose }); } function captureStdout(): { chunks: string[]; restore: () => void } { @@ -94,6 +99,141 @@ function captureStdout(): { chunks: string[]; restore: () => void } { return { chunks, restore: () => spy.mockRestore() }; } +function captureStderr(): { chunks: string[]; restore: () => void } { + const chunks: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((s: any) => { + chunks.push(String(s)); + return true; + }); + return { chunks, restore: () => spy.mockRestore() }; +} + +const cartBody = (lines: Array<{ skuId: number; quantity: number; title: string }>) => ({ + products: lines.map((line) => ({ product_id: line.skuId, title: line.title, selling_price: 10 })), + cart_items: lines.map((line) => ({ product_id: line.skuId, quantity: line.quantity, sub_total: line.quantity * 10 })), +}); + +const response = (body: unknown): Response => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => JSON.stringify(body), + json: async () => body, +}) as unknown as Response; + +const threeLines = [ + { skuId: 101, quantity: 1, title: 'One' }, + { skuId: 202, quantity: 2, title: 'Two' }, + { skuId: 303, quantity: 3, title: 'Three' }, +]; + +describe('cart mutation guard', () => { + it('detects a vanished duplicate line of the same non-target SKU', async () => { + const before = [...threeLines, { skuId: 202, quantity: 2, title: 'Two again' }]; + const after = [threeLines[1]!, threeLines[2]!]; + const replies = [cartBody(before), {}, cartBody(after)]; + globalThis.fetch = vi.fn(async () => response(replies.shift())) as any; + const ctx = await seededContext(false); + const { cartRemove } = await import('../commands/cart.js'); + const stdout = captureStdout(); + const stderr = captureStderr(); + await cartRemove(ctx, 101, { confirm: true, yes: true }); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + stderr.restore(); + expect(result.unexpectedRemovals).toEqual([{ skuId: 202, quantity: 2, title: 'Two' }]); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); + + it('removes one SKU while preserving the other lines and logs all three auth contexts', async () => { + const replies = [cartBody(threeLines), {}, cartBody(threeLines.slice(1))]; + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => response(replies.shift())); + globalThis.fetch = fetchMock as any; + const ctx = await seededContext(true); + const { cartRemove } = await import('../commands/cart.js'); + const stdout = captureStdout(); + const stderr = captureStderr(); + await cartRemove(ctx, 101, { confirm: true, yes: true }); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + stderr.restore(); + expect(result).toMatchObject({ removed: true, remaining: 2 }); + expect(result).not.toHaveProperty('unexpectedRemovals'); + expect(fetchMock.mock.calls.map((call) => (call[1] as RequestInit).method ?? 'GET')).toEqual(['GET', 'DELETE', 'GET']); + const verbose = stderr.chunks.join(''); + expect(verbose.match(/customerId=12345 authGeneration=0/g)).toHaveLength(3); + expect(verbose).toContain('DELETE body {"products":[{"id":101}]}'); + expect(process.exitCode).toBeUndefined(); + }); + + it('reports unexpectedRemovals and exits 1 when the server wipes the cart', async () => { + const replies = [cartBody(threeLines), {}, cartBody([])]; + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => response(replies.shift())); + globalThis.fetch = fetchMock as any; + const ctx = await seededContext(); + const { cartRemove } = await import('../commands/cart.js'); + const stdout = captureStdout(); + const stderr = captureStderr(); + await cartRemove(ctx, 101, { confirm: true, yes: true }); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + stderr.restore(); + expect(result.unexpectedRemovals).toEqual(threeLines.slice(1)); + expect(stderr.chunks.join('')).toContain('CART SAFETY CHECK FAILED'); + expect(process.exitCode).toBe(1); + }); + + it('guards set-qty against collateral cart changes', async () => { + const changed = [{ ...threeLines[0]!, quantity: 4 }, { ...threeLines[1]!, quantity: 1 }, threeLines[2]!]; + const replies = [cartBody(threeLines), {}, cartBody(changed)]; + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => response(replies.shift())); + globalThis.fetch = fetchMock as any; + const ctx = await seededContext(); + const { cartSetQty } = await import('../commands/cart.js'); + const stdout = captureStdout(); + const stderr = captureStderr(); + await cartSetQty(ctx, 101, 4, { confirm: true, yes: true }); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + stderr.restore(); + expect(result.unexpectedRemovals).toEqual([threeLines[1]]); + expect(process.exitCode).toBe(1); + }); + + it('rejects a missing SKU with exit 4 and sends no DELETE', async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => response(cartBody(threeLines))); + globalThis.fetch = fetchMock as any; + await seededContext(); + const { run } = await import('../cli.js'); + const { cartRemove } = await import('../commands/cart.js'); + const command = new Command().option('--json'); + command.setOptionValue('json', true); + const stdout = captureStdout(); + await run(command, (ctx) => cartRemove(ctx, 404, { confirm: true, yes: true })); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + expect(result).toMatchObject({ code: 'usage_error', error: 'SKU 404 is not in the current cart' }); + expect(process.exitCode).toBe(4); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect((fetchMock.mock.calls[0]![1] as RequestInit).method ?? 'GET').toBe('GET'); + }); + + it('lists the cart lines that would remain in a remove dry run', async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => response(cartBody(threeLines))); + globalThis.fetch = fetchMock as any; + const ctx = await seededContext(); + const { cartRemove } = await import('../commands/cart.js'); + const stdout = captureStdout(); + await cartRemove(ctx, 101); + const result = JSON.parse(stdout.chunks.join('')); + stdout.restore(); + expect(result.request.remaining).toEqual(threeLines.slice(1)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + describe('mutation gating', () => { it('a mutating command is a NO-OP under default dry-run (no write fetch)', async () => { const fetchMock = vi.fn(async () => ({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), text: async () => '{}' } as unknown as Response)); diff --git a/src/commands/cart.ts b/src/commands/cart.ts index 34f8fce..42191f4 100644 --- a/src/commands/cart.ts +++ b/src/commands/cart.ts @@ -2,6 +2,8 @@ import type { Context } from '../lib/context.js'; import { c, rand } from '../lib/ui.js'; import { gate } from './generic.js'; import type { PreferenceMatch } from '../lib/preferences.js'; +import { UsageError } from '../lib/errors.js'; +import type { CartItem, CartResult } from '../types.js'; export interface CartWriteFlags { confirm?: boolean; @@ -15,6 +17,54 @@ const REASON_LABEL: Record = { 'top-result': 'top result', }; +type CartLine = { skuId: number; quantity: number; title: string }; + +const skuOf = (item: CartItem): number => item.skuId ?? item.productId; + +const cartLines = (cart: CartResult): CartLine[] => + cart.items.map((item) => ({ skuId: skuOf(item), quantity: item.quantity, title: item.title })); + +const quantityBySku = (cart: CartResult): Map => { + const totals = new Map(); + for (const item of cart.items) totals.set(skuOf(item), (totals.get(skuOf(item)) ?? 0) + item.quantity); + return totals; +}; + +const changedLines = ( + before: CartResult, + after: CartResult, + targetSkuId: number, + targetQuantity: number | null, +): CartLine[] => { + const beforeTotals = quantityBySku(before); + const afterTotals = quantityBySku(after); + const reported = new Set(); + return cartLines(before).filter((line) => { + if (line.skuId === targetSkuId || reported.has(line.skuId)) return false; + if ((afterTotals.get(line.skuId) ?? 0) === beforeTotals.get(line.skuId)) return false; + reported.add(line.skuId); + return true; + }).concat( + targetQuantity !== null && (afterTotals.get(targetSkuId) ?? 0) !== targetQuantity + ? cartLines(before).filter((line) => line.skuId === targetSkuId).slice(0, 1) + : [], + ); +}; + +const previewLines = (lines: CartLine[]): string => + lines.length ? lines.map((line) => `${line.quantity}× ${line.title} (SKU ${line.skuId})`).join('; ') : 'none'; + +function warnRestoration(ctx: Context, unexpected: CartLine[], after: CartResult): void { + const remaining = new Set(after.items.map(skuOf)); + ctx.logger.error('CART SAFETY CHECK FAILED: other cart lines disappeared or changed quantity.'); + for (const line of unexpected) { + const command = remaining.has(line.skuId) + ? `takealot cart set-qty ${line.skuId} ${line.quantity} --confirm --yes` + : `takealot cart add --sku ${line.skuId} --qty ${line.quantity} --confirm --yes`; + ctx.logger.warn(`Restore ${line.title}: ${command}`); + } +} + /** Parse an optional leading quantity, e.g. "3 pencils" → { qty: 3, query: "pencils" }. */ function parseQuantity(raw: string): { qty: number; query: string } { const m = raw.trim().match(/^(\d+)\s+(.+)$/); @@ -154,10 +204,25 @@ export async function cartClear(ctx: Context, flags: CartWriteFlags = {}): Promi export async function cartSetQty(ctx: Context, skuId: number, quantity: number, flags: CartWriteFlags = {}): Promise { await ctx.ensureCredentials(); if (!Number.isFinite(quantity) || quantity < 1) throw new Error('quantity must be a positive integer'); - if (!(await gate(ctx, flags, { action: `set SKU ${skuId} quantity to ${quantity}` }))) return; + const before = await ctx.client.getCart(); + if (!before.items.some((item) => skuOf(item) === skuId)) throw new UsageError(`SKU ${skuId} is not in the current cart`); + const expected = cartLines(before).map((line) => line.skuId === skuId ? { ...line, quantity } : line); + const body = { products: [{ id: skuId, quantity }] }; + const request = { ...ctx.client.describeCall('cart.update', { body }), remaining: expected }; + if (!(await gate(ctx, flags, { action: `set SKU ${skuId} quantity to ${quantity}; lines after: ${previewLines(expected)}`, request }))) return; ctx.logger.info(`✏️ Setting SKU ${skuId} → qty ${quantity}…`); await ctx.client.setCartItemQuantity(skuId, quantity); const cart = await ctx.client.getCart(); + const unexpectedRemovals = changedLines(before, cart, skuId, quantity); + if (unexpectedRemovals.length) { + warnRestoration(ctx, unexpectedRemovals, cart); + ctx.logger.result( + () => process.stdout.write(`${c.red('✗')} Cart update changed other lines; restore them with the commands above.\n`), + { updated: false, skuId, quantity, remaining: cart.items.length, total: cart.total, unexpectedRemovals }, + ); + process.exitCode = 1; + return; + } ctx.logger.result( () => process.stdout.write(`${c.green('✓')} Updated SKU ${skuId} to ${quantity}. Total ${rand(cart.total)}\n`), { updated: true, skuId, quantity, total: cart.total }, @@ -167,10 +232,25 @@ export async function cartSetQty(ctx: Context, skuId: number, quantity: number, /** `cart remove ` — remove one cart line by its buyable SKU id. */ export async function cartRemove(ctx: Context, skuId: number, flags: CartWriteFlags = {}): Promise { await ctx.ensureCredentials(); - if (!(await gate(ctx, flags, { action: `remove SKU ${skuId} from the cart` }))) return; + const before = await ctx.client.getCart(); + if (!before.items.some((item) => skuOf(item) === skuId)) throw new UsageError(`SKU ${skuId} is not in the current cart`); + const remaining = cartLines(before).filter((line) => line.skuId !== skuId); + const body = { products: [{ id: skuId }] }; + const request = { ...ctx.client.describeCall('cart.remove', { body }), remaining }; + if (!(await gate(ctx, flags, { action: `remove SKU ${skuId} from the cart; lines remaining: ${previewLines(remaining)}`, request }))) return; ctx.logger.info(`➖ Removing SKU ${skuId}…`); await ctx.client.removeCartItem(skuId); const cart = await ctx.client.getCart(); + const unexpectedRemovals = changedLines(before, cart, skuId, null); + if (unexpectedRemovals.length) { + warnRestoration(ctx, unexpectedRemovals, cart); + ctx.logger.result( + () => process.stdout.write(`${c.red('✗')} Cart removal changed other lines; restore them with the commands above.\n`), + { removed: !cart.items.some((item) => skuOf(item) === skuId), skuId, remaining: cart.items.length, total: cart.total, unexpectedRemovals }, + ); + process.exitCode = 1; + return; + } ctx.logger.result( () => process.stdout.write(`${c.green('✓')} Removed SKU ${skuId}. ${cart.items.length} item(s) left.\n`), { removed: true, skuId, remaining: cart.items.length, total: cart.total }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 311d7b2..a1a9c73 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -254,6 +254,7 @@ export class TakealotClient { private async rawAuthedFetch(path: string, init: RequestInit): Promise { const url = path.startsWith('http') ? path : this.mobileApiBase + path; + const method = init.method ?? 'GET'; const headers: Record = { accept: 'application/json, */*', 'content-type': 'application/json', @@ -261,7 +262,10 @@ export class TakealotClient { ...this.auth.authHeaders(), ...((init.headers as Record) ?? {}), }; - this.logger.debug(`${init.method ?? 'GET'} ${url}`); + this.logger.debug(`${method} ${url} customerId=${this.auth.customerId ?? 'none'} authGeneration=${this.auth.currentAuthGeneration}`); + if (method === 'DELETE' && rawPathname(url).endsWith('/cart/items')) { + this.logger.debug(`DELETE body ${String(init.body ?? '')}`); + } return this.send(url, { ...init, headers }); }