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
144 changes: 142 additions & 2 deletions src/__tests__/shopping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand All @@ -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 } {
Expand All @@ -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));
Expand Down
84 changes: 82 additions & 2 deletions src/commands/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +17,54 @@ const REASON_LABEL: Record<PreferenceMatch['reason'], string> = {
'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<number, number> => {
const totals = new Map<number, number>();
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<number>();
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}`);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Medium Restore commands use line.quantity, but changedLines deduplicates by SKU and keeps only one representative line. For duplicated SKUs, this quantity is not the original per-SKU total, so the suggested set-qty command can restore the wrong amount.


/** 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+(.+)$/);
Expand Down Expand Up @@ -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<void> {
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 },
Expand All @@ -167,10 +232,25 @@ export async function cartSetQty(ctx: Context, skuId: number, quantity: number,
/** `cart remove <sku>` — remove one cart line by its buyable SKU id. */
export async function cartRemove(ctx: Context, skuId: number, flags: CartWriteFlags = {}): Promise<void> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ High Passing null as targetQuantity disables verification that the target SKU was actually removed. If removeCartItem is a no-op and the SKU remains, unexpectedRemovals stays empty and the function reports success.

Suggested change
const unexpectedRemovals = changedLines(before, cart, skuId, null);
const unexpectedRemovals = changedLines(before, cart, skuId, 0);

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 },
Expand Down
6 changes: 5 additions & 1 deletion src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,18 @@ export class TakealotClient {

private async rawAuthedFetch(path: string, init: RequestInit): Promise<Response> {
const url = path.startsWith('http') ? path : this.mobileApiBase + path;
const method = init.method ?? 'GET';
const headers: Record<string, string> = {
accept: 'application/json, */*',
'content-type': 'application/json',
'user-agent': this.mobileUA,
...this.auth.authHeaders(),
...((init.headers as Record<string, string>) ?? {}),
};
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 });
}

Expand Down
Loading