diff --git a/src/CommerceAdmin.php b/src/CommerceAdmin.php index 49e3c9c..69b3364 100644 --- a/src/CommerceAdmin.php +++ b/src/CommerceAdmin.php @@ -7,29 +7,42 @@ use Nimbus\Plugin\PluginStorage; /** - * The Commerce admin page — a read-only list of orders with their status, total - * and lines. Registered as a GET-only plugin admin page; orders are placed and - * moved through the MCP tools (or an agent), so this is a window, not an editor. + * The Commerce admin page — the orders list with their status, total and lines, + * a quick place-order form, and per-row lifecycle buttons (pay, fulfil, cancel). + * The same lifecycle an agent drives over MCP; this is the human hand on it. * * Customer emails and SKU codes can originate from callers, so every value is - * escaped before it reaches the page. + * escaped before it reaches the page. Status "pills" use the admin theme's + * semantic tokens (which redefine per theme) rather than hard-coded colours, so + * they stay legible in dark and every selectable theme. */ final class CommerceAdmin { + /** status => [background token, text token] — all theme-defined, so dark-safe. */ private const STATUS_TONE = [ - 'pending' => '#9a6a12', - 'paid' => '#0f766e', - 'fulfilled' => '#5751d6', - 'cancelled' => '#a5386b', + 'pending' => ['--nb-warn-bg', '--nb-warn-text'], + 'paid' => ['--nb-brand-tint', '--nb-link-color'], + 'fulfilled' => ['--nb-ok-bg', '--nb-ok-text'], + 'cancelled' => ['--nb-surface-2', '--nb-muted'], + ]; + + /** A tiny symbol map — no ext-intl dependency; unknown codes render as "12.50 XYZ". */ + private const SYMBOL = [ + 'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'JPY' => '¥', + 'AUD' => 'A$', 'CAD' => 'C$', 'NZD' => 'NZ$', 'INR' => '₹', ]; private const NOTICES = [ - 'placed' => ['ok', 'Order placed and stock reserved.'], - 'paid' => ['ok', 'Order marked paid.'], - 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'], - 'cancelled' => ['ok', 'Order cancelled — stock released.'], - 'short' => ['err', 'Not enough stock available to place that order.'], - 'invalid' => ['err', 'Check the SKU and quantity and try again.'], + 'placed' => ['ok', 'Order placed and stock reserved.'], + 'paid' => ['ok', 'Order marked paid.'], + 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'], + 'cancelled' => ['ok', 'Order cancelled — stock released.'], + 'short' => ['err', 'Not enough stock available to place that order.'], + 'badqty' => ['err', 'Enter a valid quantity and unit price.'], + 'noinventory' => ['err', 'Install the Inventory plugin — an order reserves stock against it.'], + 'notfound' => ['err', 'No order with that reference.'], + 'badstate' => ['err', 'That order can’t move to that state.'], + 'invalid' => ['err', 'Check the SKU and quantity and try again.'], ]; /** @param \Closure():PluginStorage $storage */ @@ -40,11 +53,23 @@ public function __construct(private \Closure $storage) /** * @param string $csrf CSRF token for the forms (passed by core to the page handler) * @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect) + * @param ?string $status a status filter (from ?status=), allow-listed to the known statuses */ - public function render(string $csrf = '', ?string $notice = null): string + public function render(string $csrf = '', ?string $notice = null, ?string $status = null): string { - $s = ($this->storage)(); - $orders = $s->select('SELECT id, reference, status, customer_email, total, placed_at FROM ' . Schema::ORDER . ' ORDER BY id DESC LIMIT 50'); + $s = ($this->storage)(); + + // Allow-list the filter: an unknown value is ignored (never reaches SQL). + $status = ($status !== null && isset(self::STATUS_TONE[$status])) ? $status : null; + $where = $status === null ? '' : ' WHERE status = :status'; + $params = $status === null ? [] : ['status' => $status]; + $orders = $s->select('SELECT id, reference, status, customer_email, currency, total, placed_at FROM ' . Schema::ORDER . $where . ' ORDER BY id DESC LIMIT 50', $params); + + /** @var list $skus SKUs sold before — the place-form suggestions (own table; boundary-safe) */ + $skus = array_map( + static fn (array $r): string => (string) $r['sku_code'], + $s->select('SELECT DISTINCT sku_code FROM ' . Schema::LINE . ' ORDER BY sku_code'), + ); $banner = ''; if ($notice !== null && isset(self::NOTICES[$notice])) { @@ -59,49 +84,130 @@ public function render(string $csrf = '', ?string $notice = null): string $html = '

Commerce

' . $banner . '

Orders reserve stock against Inventory. ' - . 'Place a quick order below, or drive the full lifecycle (pay, fulfil, cancel) over MCP.

' + . 'Place an order below and advance it with the buttons, or drive the lifecycle over MCP.

' + . $this->datalist($skus) . $this->placeForm($csrf); + $html .= $this->statusFilter($status); + if ($orders === []) { - $html .= '

No orders yet. Place one with the shop_place_order tool.

'; + $html .= $status === null + ? '

No orders yet. Place one above, or with the shop_place_order tool.

' + : '

No ' . $this->e($status) . ' orders.

'; return $html; } $html .= '
' . '' - . ''; + . ''; foreach ($orders as $o) { - $status = (string) $o['status']; - $tone = self::STATUS_TONE[$status] ?? '#565d6d'; - $items = []; + $items = []; foreach ($lines[(int) $o['id']] ?? [] as $ln) { $items[] = $this->e((string) $ln['qty']) . ' × ' . $this->e((string) $ln['sku_code']) . ''; } $html .= '' - . '' + . '' . '' . '' - . '' - . ''; + . '' + . '' + . ''; } $html .= '
OrderStatusCustomerItemsTotalPlaced
TotalPlacedActions
' . $this->e((string) $o['reference']) . '' . $this->e($status) . '' . $this->pill((string) $o['status']) . '' . $this->e((string) ($o['customer_email'] ?? '—')) . '' . implode(', ', $items) . '$' . $this->e((string) $o['total']) . '' . $this->e((string) $o['placed_at']) . '
' . $this->money((string) $o['total'], (string) $o['currency']) . '' . $this->e((string) $o['placed_at']) . '' . $this->actions((string) $o['reference'], (string) $o['status'], $csrf) . '
'; return $html; } + /** A coloured status pill using theme tokens (dark-safe). */ + private function pill(string $status): string + { + [$bg, $fg] = self::STATUS_TONE[$status] ?? ['--nb-surface-2', '--nb-muted']; + + return '' . $this->e($status) . ''; + } + + /** The lifecycle buttons valid for this order's status; each is a CSRF-protected POST. */ + private function actions(string $reference, string $status, string $csrf): string + { + $verbs = match ($status) { + 'pending' => ['pay' => 'Pay', 'cancel' => 'Cancel'], + 'paid' => ['fulfil' => 'Fulfil', 'cancel' => 'Cancel'], + default => [], + }; + if ($verbs === []) { + return ''; + } + + $out = '
'; + foreach ($verbs as $action => $label) { + $primary = $action === 'cancel' ? '' : ' nb-btn-primary'; + $out .= '
' + . '' + . '' + . '' + . '
'; + } + return $out . '
'; + } + + /** Status filter chips (GET links, allow-listed). */ + private function statusFilter(?string $active): string + { + $chip = function (string $label, ?string $status) use ($active): string { + $on = $status === $active; + $href = $status === null ? '/admin/commerce' : '/admin/commerce?status=' . rawurlencode($status); + $cls = 'nb-btn' . ($on ? ' nb-btn-primary' : ''); + return '' . $this->e($label) . ''; + }; + + $out = '
' + . $chip('All', null); + foreach (array_keys(self::STATUS_TONE) as $status) { + $out .= $chip(ucfirst($status), $status); + } + return $out . '
'; + } + + /** + * Known-SKU suggestions for the place form, from this plugin's own order lines. + * + * @param list $skus + */ + private function datalist(array $skus): string + { + $out = ''; + foreach ($skus as $sku) { + $out .= ''; + } + return $out . ''; + } + + private function money(string $amount, string $currency): string + { + $currency = strtoupper(trim($currency)) ?: 'USD'; + $symbol = self::SYMBOL[$currency] ?? ''; + + return $symbol !== '' + ? $this->e($symbol . $amount) + : $this->e($amount . ' ' . $currency); + } + /** A quick single-line place-order form (posts to the plugin admin action with CSRF). */ private function placeForm(string $csrf): string { - $f = static fn (string $label, string $name, string $ph): string => - '
' - . '
'; + $f = function (string $label, string $name, string $ph, bool $suggest = false): string { + $list = $suggest ? ' list="ord-skus"' : ''; + return '
' + . '
'; + }; return '
' . '

Place an order

' . '' . '
' - . $f('SKU', 'sku', 'house-blend') + . $f('SKU', 'sku', 'house-blend', true) . $f('Location', 'location', 'main') . $f('Qty', 'qty', '2') . $f('Unit price', 'unit_price', '12.50') @@ -110,7 +216,7 @@ private function placeForm(string $csrf): string . '
'; } - private function e(string $v): string + public function e(string $v): string { return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); } diff --git a/src/CommercePlugin.php b/src/CommercePlugin.php index 86d95a1..c991ea6 100644 --- a/src/CommercePlugin.php +++ b/src/CommercePlugin.php @@ -42,24 +42,34 @@ public function register(PluginContext $context): void $context->mcp()->register(new CommerceToolset($orders)); - // Admin page: an orders overview + a quick place-order form (H3). + // Admin page: an orders overview + place form + per-row lifecycle buttons + // (H3). Gated on this plugin's own wildcard-immune capability (ADR 0020) — + // advancing an order in the UI needs `nimbuscms.commerce:write`, exactly + // like the MCP tools, so a content-only editor can't. $context->adminPages()->register( 'commerce', 'Commerce', '🧾', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err')), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('status')), + self::ID . ':write', ); $context->adminPages()->action('commerce', 'place', static function (Request $r) use ($orders): Response { - $sku = trim((string) ($r->input('sku') ?? '')); - $qty = trim((string) ($r->input('qty') ?? '')); + $sku = trim((string) ($r->input('sku') ?? '')); + $qty = trim((string) ($r->input('qty') ?? '')); + $price = trim((string) ($r->input('unit_price') ?? '')) ?: '0'; if ($sku === '' || $qty === '') { return Response::redirect('/admin/commerce?err=invalid'); } + // Validate the numbers at the boundary so a non-numeric qty/price is an + // honest "badqty" notice, not a database error surfacing as something else. + if (preg_match('/^\d+(\.\d{1,4})?$/', $qty) !== 1 || preg_match('/^\d+(\.\d{1,2})?$/', $price) !== 1) { + return Response::redirect('/admin/commerce?err=badqty'); + } $line = [ 'sku' => $sku, 'location' => trim((string) ($r->input('location') ?? '')) ?: 'main', 'qty' => $qty, - 'unit_price' => trim((string) ($r->input('unit_price') ?? '')) ?: '0', + 'unit_price' => $price, ]; $email = trim((string) ($r->input('customer_email') ?? '')) ?: null; try { @@ -67,11 +77,41 @@ public function register(PluginContext $context): void return Response::redirect('/admin/commerce?ok=placed'); } catch (\NimbusCMS\Inventory\InsufficientStock) { return Response::redirect('/admin/commerce?err=short'); + } catch (NoInventory) { + return Response::redirect('/admin/commerce?err=noinventory'); + } catch (\InvalidArgumentException) { + return Response::redirect('/admin/commerce?err=badqty'); } catch (\Throwable) { return Response::redirect('/admin/commerce?err=invalid'); } }); + // The lifecycle actions — the UI catching up to the MCP tools. Each reads + // the order reference, advances it, and maps a typed failure to an honest + // notice (unknown order vs illegal transition). + foreach ([ + 'pay' => static fn (OrderBook $o, string $ref): array => $o->pay($ref, date('Y-m-d H:i:s')), + 'fulfil' => static fn (OrderBook $o, string $ref): array => $o->fulfil($ref, 'admin-ui', date('Y-m-d H:i:s')), + 'cancel' => static fn (OrderBook $o, string $ref): array => $o->cancel($ref, date('Y-m-d H:i:s')), + ] as $action => $run) { + $context->adminPages()->action('commerce', $action, static function (Request $r) use ($orders, $run, $action): Response { + $ref = trim((string) ($r->input('reference') ?? '')); + if ($ref === '') { + return Response::redirect('/admin/commerce?err=invalid'); + } + try { + $run($orders, $ref); + return Response::redirect('/admin/commerce?ok=' . ($action === 'pay' ? 'paid' : ($action === 'fulfil' ? 'fulfilled' : 'cancelled'))); + } catch (OrderNotFound) { + return Response::redirect('/admin/commerce?err=notfound'); + } catch (IllegalTransition) { + return Response::redirect('/admin/commerce?err=badstate'); + } catch (\Throwable) { + return Response::redirect('/admin/commerce?err=invalid'); + } + }); + } + $context->skills()->register('Commerce', Guide::text()); } } diff --git a/src/IllegalTransition.php b/src/IllegalTransition.php new file mode 100644 index 0000000..bbaa478 --- /dev/null +++ b/src/IllegalTransition.php @@ -0,0 +1,19 @@ +stock(); if ($port === null) { - throw new \RuntimeException('No inventory plugin is installed, so stock cannot be reserved — an order cannot be placed.'); + throw new NoInventory('No inventory plugin is installed, so stock cannot be reserved — an order cannot be placed.'); } if ($lines === []) { throw new \InvalidArgumentException('An order needs at least one line.'); @@ -124,7 +124,7 @@ public function fulfil(string $ref, string $actor, string $now): array { $order = $this->requireOrder($ref); if ($order['status'] !== self::PAID) { - throw new \RuntimeException("Only a paid order can be fulfilled (this one is \"{$order['status']}\")."); + throw new IllegalTransition((string) $order['status'], self::FULFILLED); } $this->storage()->transaction(function () use ($order, $ref, $actor, $now): void { @@ -147,7 +147,7 @@ public function cancel(string $ref, string $now): array { $order = $this->requireOrder($ref); if ($order['status'] === self::FULFILLED) { - throw new \RuntimeException('A fulfilled order cannot be cancelled.'); + throw new IllegalTransition(self::FULFILLED, self::CANCELLED); } if ($order['status'] === self::CANCELLED) { return $this->get($ref) ?? throw new \RuntimeException('Unknown order.'); @@ -203,7 +203,7 @@ private function requireOrder(string $ref): array { $order = $this->storage()->selectOne('SELECT * FROM ' . Schema::ORDER . ' WHERE reference = :ref', ['ref' => $ref]); if ($order === null) { - throw new \RuntimeException("No order with reference \"{$ref}\"."); + throw new OrderNotFound($ref); } return $order; } @@ -212,7 +212,7 @@ private function transition(string $ref, string $from, string $to, string $now): { $order = $this->requireOrder($ref); if ($order['status'] !== $from) { - throw new \RuntimeException("An order can only move to \"{$to}\" from \"{$from}\" (this one is \"{$order['status']}\")."); + throw new IllegalTransition((string) $order['status'], $to); } $this->setStatus((int) $order['id'], $to, $now); } diff --git a/src/OrderNotFound.php b/src/OrderNotFound.php new file mode 100644 index 0000000..f0803ee --- /dev/null +++ b/src/OrderNotFound.php @@ -0,0 +1,19 @@ + */ + private array $actions; + + protected function setUp(): void + { + $this->db = new Connection([ + 'host' => getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...InventorySchema::all(), ...InventorySchema::reservations(), ...CommerceSchema::all()] as $sql) { + $this->db->execute($sql); + } + foreach ([InventorySchema::MOVEMENT, InventorySchema::STOCK, InventorySchema::LOCATION, InventorySchema::RESERVATION, CommerceSchema::ORDER, CommerceSchema::LINE] as $t) { + $this->db->execute('TRUNCATE ' . $t); + } + + // Stock a SKU so orders can be placed. + $ledger = new Ledger(fn (): PluginStorage => new PluginStorage($this->db)); + $ledger->receive('LATTE', $ledger->ensureLocation('main', 'Main', '2026-01-01 09:00:00'), '10', 'each', 'setup', '2026-01-01 09:00:00'); + + // Install BOTH packages so Commerce resolves Inventory's port from the shared + // service registry — the real cross-plugin wiring. + $commerce = json_decode((string) file_get_contents(__DIR__ . '/../composer.json'), true); + $inventory = json_decode((string) file_get_contents(__DIR__ . '/../vendor/nimbuscms/inventory/composer.json'), true); + $pkg = static fn (array $m): array => ['name' => $m['name'], 'type' => $m['type'], 'extra' => $m['extra']]; + $this->installedJson = (string) tempnam(sys_get_temp_dir(), 'nb-installed-'); + file_put_contents($this->installedJson, json_encode(['packages' => [$pkg($inventory), $pkg($commerce)]], JSON_THROW_ON_ERROR)); + + $adminPages = new AdminPageRegistry(); + $diagnostics = (new PluginLoader($this->installedJson))->load(new PluginCapabilities( + adminPages: $adminPages, + services: new ServiceRegistry(), + db: $this->db, + )); + self::assertSame([], $diagnostics, 'both plugins load cleanly (Commerce page gated on nimbuscms.commerce:write)'); + + $this->actions = []; + foreach ($adminPages->actions() as $a) { + if ($a['provider'] === 'nimbuscms.commerce') { + $this->actions[$a['action']] = $a['handler']; + } + } + } + + protected function tearDown(): void + { + @unlink($this->installedJson); + } + + /** @param array $input */ + private function post(string $action, array $input): Response + { + return ($this->actions[$action])(new Request('POST', '/admin/commerce/' . $action, [], $input, [], [])); + } + + private function place(): string + { + $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => '2', 'unit_price' => '4.50']); + self::assertSame('/admin/commerce?ok=placed', $r->header('Location'), 'placing succeeds when stock is available'); + $ref = $this->db->selectOne('SELECT reference FROM ' . CommerceSchema::ORDER . ' ORDER BY id DESC LIMIT 1'); + return (string) $ref['reference']; + } + + public function test_place_reserves_and_redirects_ok(): void + { + $this->place(); + } + + public function test_place_more_than_available_is_short(): void + { + $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => '999', 'unit_price' => '4.50']); + self::assertSame('/admin/commerce?err=short', $r->header('Location')); + } + + public function test_place_a_bad_quantity_is_badqty(): void + { + $r = $this->post('place', ['sku' => 'LATTE', 'location' => 'main', 'qty' => 'lots', 'unit_price' => '4.50']); + self::assertSame('/admin/commerce?err=badqty', $r->header('Location')); + } + + public function test_pay_then_fulfil_advances_the_order(): void + { + $ref = $this->place(); + + self::assertSame('/admin/commerce?ok=paid', $this->post('pay', ['reference' => $ref])->header('Location')); + self::assertSame('/admin/commerce?ok=fulfilled', $this->post('fulfil', ['reference' => $ref])->header('Location')); + } + + public function test_cancel_releases_the_hold(): void + { + $ref = $this->place(); + self::assertSame('/admin/commerce?ok=cancelled', $this->post('cancel', ['reference' => $ref])->header('Location')); + } + + public function test_paying_an_unknown_order_is_notfound(): void + { + $r = $this->post('pay', ['reference' => 'ORD-NOPE']); + self::assertSame('/admin/commerce?err=notfound', $r->header('Location')); + } + + public function test_fulfilling_an_unpaid_order_is_badstate(): void + { + $ref = $this->place(); // still pending + $r = $this->post('fulfil', ['reference' => $ref]); + self::assertSame('/admin/commerce?err=badstate', $r->header('Location')); + } + + public function test_a_missing_reference_is_invalid(): void + { + self::assertSame('/admin/commerce?err=invalid', $this->post('pay', [])->header('Location')); + } +} diff --git a/tests/CommerceAdminTest.php b/tests/CommerceAdminTest.php new file mode 100644 index 0000000..f130ac4 --- /dev/null +++ b/tests/CommerceAdminTest.php @@ -0,0 +1,117 @@ +db = new Connection([ + 'host' => getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (Schema::all() as $sql) { + $this->db->execute($sql); + } + $this->db->execute('TRUNCATE ' . Schema::ORDER); + $this->db->execute('TRUNCATE ' . Schema::LINE); + + $this->storage = new PluginStorage($this->db); + $this->admin = new CommerceAdmin(fn (): PluginStorage => $this->storage); + } + + private function order(string $ref, string $status, string $currency, string $total, string $sku): void + { + $oid = $this->storage->insert( + 'INSERT INTO ' . Schema::ORDER . ' (reference, status, customer_email, currency, total, placed_at, updated_at) + VALUES (:r, :s, :e, :c, :t, :n, :n2)', + ['r' => $ref, 's' => $status, 'e' => 'buyer@test.local', 'c' => $currency, 't' => $total, 'n' => '2026-01-01 09:00:00', 'n2' => '2026-01-01 09:00:00'], + ); + $this->storage->insert( + 'INSERT INTO ' . Schema::LINE . ' (order_id, sku_code, location, qty, unit_price) VALUES (:o, :sku, :loc, :q, :p)', + ['o' => $oid, 'sku' => $sku, 'loc' => 'main', 'q' => '2', 'p' => '6.25'], + ); + } + + public function test_status_pills_use_theme_tokens_not_hard_coded_colours(): void + { + $this->order('ORD-1', 'pending', 'USD', '12.50', 'house-blend'); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('background:var(--nb-warn-bg);color:var(--nb-warn-text)', $html, 'pending pill uses theme tokens'); + self::assertStringNotContainsString('color:#fff', $html, 'no hard-coded pill colours'); + self::assertStringNotContainsString('#9a6a12', $html, 'no legacy hex tones'); + } + + public function test_totals_render_in_the_orders_currency(): void + { + $this->order('ORD-USD', 'pending', 'USD', '12.50', 'house-blend'); + $this->order('ORD-EUR', 'paid', 'EUR', '9.00', 'oat-milk'); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('$12.50', $html); + self::assertStringContainsString('€9.00', $html); + } + + public function test_an_unknown_currency_falls_back_to_the_code(): void + { + $this->order('ORD-X', 'pending', 'ZZZ', '5.00', 'thing'); + self::assertStringContainsString('5.00 ZZZ', $this->admin->render('tok')); + } + + public function test_lifecycle_buttons_match_the_status(): void + { + $this->order('ORD-P', 'pending', 'USD', '1.00', 'a'); + $this->order('ORD-F', 'fulfilled', 'USD', '1.00', 'b'); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('action="/admin/commerce/pay"', $html, 'a pending order can be paid'); + self::assertStringContainsString('action="/admin/commerce/cancel"', $html, 'and cancelled'); + // A fulfilled order is terminal — no lifecycle buttons. + self::assertStringNotContainsString('action="/admin/commerce/fulfil"', $html); + } + + public function test_the_status_filter_is_allow_listed_and_narrows(): void + { + $this->order('ORD-P', 'pending', 'USD', '1.00', 'a'); + $this->order('ORD-D', 'paid', 'USD', '1.00', 'b'); + + $paid = $this->admin->render('tok', null, 'paid'); + self::assertStringContainsString('ORD-D', $paid); + self::assertStringNotContainsString('ORD-P', $paid); + + // A junk status is ignored (treated as no filter) and never reflected raw. + $junk = $this->admin->render('tok', null, '">'); + self::assertStringContainsString('ORD-P', $junk, 'junk filter falls back to all orders'); + self::assertStringNotContainsString('', $junk); + } + + public function test_the_place_form_suggests_previously_ordered_skus(): void + { + $this->order('ORD-1', 'pending', 'USD', '1.00', 'house-blend'); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('', $html); + self::assertStringContainsString('