2 Commits

Author SHA1 Message Date
Ty 2e2be48154 Merge pull request 'SETTINGS-35: species $/bdft in Settings + taxonomy parent fix (#76)' (#85) from issue-76-settings-species-pricing into main 2026-09-20 19:39:23 +00:00
Ty 18c262b5b4 SETTINGS-35: species $/bdft in Settings + taxonomy parent fix (#76)
Add Settings species pricing UI (children of Species grouper by slug,
never hardcoded ids). Map Woo REST parent→parent_id on taxonomy sync;
upsert categories so slab FKs survive re-sync. Tests: parent map, FK-safe
sync, TV-8 0.96×12.50→12.00, Playwright mock e2e. UAT verified on 5060.
2026-09-20 12:37:52 -07:00
7 changed files with 655 additions and 5 deletions
+45 -5
View File
@@ -20,6 +20,23 @@ def _scoped_tags(tags: list[dict]) -> list[dict]:
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
def _normalize_category(c: dict) -> tuple:
"""Map a Woo REST category row into woo_categories insert values.
WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may
already use `parent_id`. Never invent ids — only store what the payload carries.
"""
raw_parent = c.get("parent_id", c.get("parent"))
if raw_parent in (0, "0", None, ""):
parent_id = None
else:
parent_id = int(raw_parent)
woo_id = c.get("woo_id", c.get("id"))
if woo_id is not None:
woo_id = int(woo_id)
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
def _subset_hash(categories: list[dict], attributes: list[dict],
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
subset = {
@@ -40,14 +57,37 @@ def sync(client: WooClient, conn) -> dict:
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
scoped_tags = _scoped_tags(tags)
# Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild.
conn.execute("DELETE FROM woo_attribute_terms")
conn.execute("DELETE FROM woo_attributes")
conn.execute("DELETE FROM woo_categories")
conn.execute("DELETE FROM woo_tags")
conn.executemany(
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?)",
[(c["id"], c["name"], c["slug"], c.get("parent_id"), c.get("woo_id")) for c in categories],
)
# Categories: upsert then drop stale rows. A full DELETE fails when slabs or
# pricing_rules still reference species_id (live UAT IntegrityError on test-woo).
cat_rows = [_normalize_category(c) for c in categories]
cat_ids = [r[0] for r in cat_rows]
for row in cat_rows:
conn.execute(
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET "
"name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id",
row,
)
if cat_ids:
placeholders = ",".join("?" * len(cat_ids))
conn.execute(
f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) "
"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
"AND id NOT IN (SELECT species_id FROM pricing_rules)",
cat_ids,
)
else:
conn.execute(
"DELETE FROM woo_categories "
"WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
"AND id NOT IN (SELECT species_id FROM pricing_rules)"
)
conn.executemany(
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
+41
View File
@@ -87,6 +87,47 @@ def test_price_suggestion_from_rate(env):
assert body["price"] == 184.0
def test_price_suggestion_tv8_quality_90(env):
"""QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00."""
client, conn = env
conn.execute(
"INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'t','t')"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
"s1",
"SLAB-01",
114.0,
1.75,
30.0,
0.96,
18.0,
24.3,
24.3,
1,
json.dumps([2]),
json.dumps([1]),
None,
None,
None,
None,
"A slab",
"manual",
"calibrated",
1,
1,
"t",
"t",
),
)
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["price_suggestion"] == 12.00
assert body["price"] == 12.00
def test_knife_blank_carries_dims(env):
client, conn = env
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)")
+69
View File
@@ -141,6 +141,75 @@ def test_anchor_stable_across_identical_syncs(db_dir):
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"]
def test_sync_maps_woo_parent_field_to_parent_id(db_dir):
"""Woo REST categories use `parent` (0 = top-level), not `parent_id`.
Live UAT lost hierarchy when sync only read parent_id (SETTINGS-35 / METADATA-10).
Fixture ids are local to this test — not production store ids.
"""
cats = (
200,
{"X-WP-TotalPages": "1"},
json.dumps(
[
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
{"id": 20, "name": "Wood category", "slug": "wcat", "parent": 0},
{"id": 21, "name": "Live edge wood", "slug": "liveedge", "parent": 20},
]
).encode(),
)
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
c, _ = _client([cats, tags, attrs])
taxonomy_mod.sync(c, db_mod.get_connection())
conn = db_mod.get_connection()
rows = {
r[0]: {"name": r[1], "slug": r[2], "parent_id": r[3], "woo_id": r[4]}
for r in conn.execute("SELECT id, name, slug, parent_id, woo_id FROM woo_categories")
}
assert rows[10]["parent_id"] is None
assert rows[11]["parent_id"] == 10
assert rows[20]["parent_id"] is None
assert rows[21]["parent_id"] == 20
# woo_id falls back to Woo category id when the payload has no woo_id field
assert rows[11]["woo_id"] == 11
def test_sync_with_slab_species_fk_does_not_fail(db_dir):
"""Live UAT: DELETE woo_categories fails when a slab still points at species_id."""
conn = db_mod.get_connection()
conn.execute(
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Old Cedar','old-cedar',NULL,11)"
)
conn.execute(
"INSERT INTO slabs(id, status, species_id, server_rev, created_at, updated_at) "
"VALUES ('slab-fk-1', 'draft', 11, 1, 't', 't')"
)
conn.commit()
cats = (
200,
{"X-WP-TotalPages": "1"},
json.dumps(
[
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
]
).encode(),
)
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
c, _ = _client([cats, tags, attrs])
taxonomy_mod.sync(c, conn)
rows = {
r[0]: r[3]
for r in conn.execute("SELECT id, name, slug, parent_id FROM woo_categories")
}
assert rows[11] == 10 # parent mapped; row kept for FK
assert conn.execute("SELECT species_id FROM slabs WHERE id='slab-fk-1'").fetchone()[0] == 11
def test_403_raises_forbidden_error(db_dir):
def fake_request(url, method, data, headers):
raise WooForbiddenError("request forbidden (403)")
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
interface SecretState { configured: boolean }
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
@@ -18,6 +19,20 @@
woo_create_status: string | null;
}
interface PriceRule {
id: number;
species_id: number;
price_per_bdft: number;
}
interface RateRow {
species_id: number;
name: string;
slug: string;
ruleId: number | null;
rateText: string;
}
let settings: Settings | null = null;
let loading = true;
let saving = false;
@@ -26,11 +41,155 @@
// local edit buffers (write-only for secrets)
let form: any = {};
let rateRows: RateRow[] = [];
let ratesLoading = false;
let ratesSaving = false;
let ratesMessage = '';
let taxonomyPresent = false;
// Unmasked UAT mode returns the secret as a plain string; masked mode returns {configured}.
const isUnmasked = (s: Secret) => typeof s === 'string';
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
function decodeName(name: string): string {
// Woo sometimes returns HTML entities in category names.
return name
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function loadRates() {
ratesLoading = true;
ratesMessage = '';
try {
const [taxRes, priceRes] = await Promise.all([
fetch('/api/v1/admin/taxonomy'),
fetch('/api/v1/admin/pricing'),
]);
if (!taxRes.ok) {
taxonomyPresent = false;
rateRows = [];
ratesMessage = 'Could not load taxonomy. Run Test WooCommerce connection first.';
ratesLoading = false;
return;
}
const tax = await taxRes.json();
const categories = (tax.categories || []) as TaxonomyCategory[];
taxonomyPresent = categories.length > 0;
const rules: PriceRule[] = priceRes.ok ? await priceRes.json() : [];
const bySpecies = new Map<number, PriceRule>();
for (const r of rules) bySpecies.set(r.species_id, r);
const leaves = speciesLeavesFromTaxonomy(categories);
rateRows = leaves.map((leaf) => {
const rule = bySpecies.get(leaf.id);
return {
species_id: leaf.id,
name: decodeName(leaf.name),
slug: leaf.slug,
ruleId: rule?.id ?? null,
rateText: rule != null ? String(rule.price_per_bdft) : '',
};
});
if (!taxonomyPresent) {
ratesMessage = 'No taxonomy yet. Run Test WooCommerce connection to sync species leaves.';
} else if (leaves.length === 0) {
ratesMessage =
'Taxonomy is present but no species leaves were found under the Species grouper. Re-run Test Woo after updating the app, then sync again.';
}
} catch {
ratesMessage = 'Failed to load species rates';
rateRows = [];
taxonomyPresent = false;
}
ratesLoading = false;
}
async function saveRates() {
ratesSaving = true;
ratesMessage = '';
let saved = 0;
let failed = 0;
for (const row of rateRows) {
const raw = String(row.rateText ?? '').trim();
if (raw === '') continue; // blank = leave unchanged (no delete)
const rate = Number(raw);
if (!Number.isFinite(rate) || rate < 0) {
failed += 1;
continue;
}
try {
if (row.ruleId != null) {
const res = await fetch(`/api/v1/admin/pricing/${row.ruleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price_per_bdft: rate }),
});
if (!res.ok) {
failed += 1;
continue;
}
const body = await res.json();
row.ruleId = body.id;
row.rateText = String(body.price_per_bdft);
saved += 1;
} else {
const res = await fetch('/api/v1/admin/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ species_id: row.species_id, price_per_bdft: rate }),
});
if (res.status === 409) {
// Rule exists — refresh list id then PUT
const listRes = await fetch('/api/v1/admin/pricing');
if (!listRes.ok) {
failed += 1;
continue;
}
const list: PriceRule[] = await listRes.json();
const existing = list.find((r) => r.species_id === row.species_id);
if (!existing) {
failed += 1;
continue;
}
const put = await fetch(`/api/v1/admin/pricing/${existing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price_per_bdft: rate }),
});
if (!put.ok) {
failed += 1;
continue;
}
const body = await put.json();
row.ruleId = body.id;
row.rateText = String(body.price_per_bdft);
saved += 1;
} else if (res.ok || res.status === 201) {
const body = await res.json();
row.ruleId = body.id;
row.rateText = String(body.price_per_bdft);
saved += 1;
} else {
failed += 1;
}
}
} catch {
failed += 1;
}
}
rateRows = [...rateRows];
if (failed && !saved) ratesMessage = 'Save rates failed';
else if (failed) ratesMessage = `Saved ${saved}; ${failed} failed`;
else if (saved) ratesMessage = `Saved ${saved} rate${saved === 1 ? '' : 's'}`;
else ratesMessage = 'No rate changes to save';
ratesSaving = false;
}
async function load() {
loading = true;
try {
@@ -56,6 +215,7 @@
message = 'Failed to load settings';
}
loading = false;
void loadRates();
}
async function save() {
@@ -108,6 +268,7 @@
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
const body = await res.json();
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
if (wooTest.ok) await loadRates();
} catch {
wooTest = { ok: false, detail: 'network error' };
}
@@ -201,6 +362,46 @@
{/if}
</div>
<div class="section" data-testid="species-pricing">
<h3>Species pricing</h3>
<p class="hint">One flat $ / bdft per synced species leaf (SETTINGS-35). Leaves come from taxonomy under Species — never a hardcoded list.</p>
{#if ratesLoading}
<p>Loading rates…</p>
{:else if rateRows.length === 0}
<p class="msg err" data-testid="species-pricing-empty">
{ratesMessage || 'Run Test WooCommerce connection first to sync species leaves.'}
</p>
{:else}
<div class="rate-table" role="table" aria-label="Species rates">
<div class="rate-head" role="row">
<span role="columnheader">Species</span>
<span role="columnheader">$ / bdft</span>
</div>
{#each rateRows as row (row.species_id)}
<div class="rate-row" role="row" data-species-id={row.species_id} data-species-slug={row.slug}>
<label class="species-name" for={`rate-${row.species_id}`}>{row.name}</label>
<input
id={`rate-${row.species_id}`}
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={row.rateText}
placeholder="—"
aria-label={`Price per bdft for ${row.name}`}
/>
</div>
{/each}
</div>
<button on:click={saveRates} disabled={ratesSaving || saving} data-testid="save-rates">
{ratesSaving ? 'Saving rates…' : 'Save rates'}
</button>
{/if}
{#if ratesMessage && rateRows.length > 0}
<p class="msg" class:ok={!ratesMessage.includes('fail')} class:err={ratesMessage.includes('fail')}>{ratesMessage}</p>
{/if}
</div>
<div class="section">
<h3>Inference</h3>
<button on:click={testInference} disabled={saving || inferenceTest?.busy}>Test Inference connection</button>
@@ -220,4 +421,18 @@
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
.section h3 { margin: 0 0 8px; font-size: 1.05rem; color: #eee; }
.section button { margin-top: 4px; }
.hint { margin: 0 0 10px; color: #bbb; font-size: 0.9rem; }
.msg { color: #ccc; }
.msg.ok { color: #7fd37f; }
.msg.err { color: #e88; }
.rate-table { display: grid; gap: 6px; }
.rate-head, .rate-row {
display: grid;
grid-template-columns: 1fr 7rem;
gap: 8px;
align-items: center;
}
.rate-head { color: #aaa; font-size: 0.85rem; }
.species-name { margin: 0; color: #eee; }
.rate-row input { margin: 0; }
</style>
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
/** Fixture ids are local to this test — never production store ids. */
const tree: TaxonomyCategory[] = [
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
{ id: 11, name: 'Cedar', slug: 'cedar', parent_id: 10 },
{ id: 12, name: 'Maple', slug: 'maple', parent_id: 10 },
{ id: 20, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: 21, name: 'Live edge wood', slug: 'liveedge', parent_id: 20 },
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
];
describe('speciesLeavesFromTaxonomy', () => {
it('returns only children of the Species grouper, sorted by name', () => {
const leaves = speciesLeavesFromTaxonomy(tree);
assert.deepEqual(
leaves.map((l) => l.slug),
['cedar', 'maple']
);
assert.equal(leaves[0].name, 'Cedar');
assert.equal(leaves[1].name, 'Maple');
});
it('returns empty when Species grouper is missing (no dump of all categories)', () => {
const flat = tree.filter((c) => c.slug !== 'species');
assert.deepEqual(speciesLeavesFromTaxonomy(flat), []);
});
it('returns empty when Species has no children', () => {
assert.deepEqual(
speciesLeavesFromTaxonomy([{ id: 10, name: 'Species', slug: 'species', parent_id: null }]),
[]
);
});
it('resolves Species grouper by name when slug differs', () => {
const alt: TaxonomyCategory[] = [
{ id: 10, name: 'Species', slug: 'product-species', parent_id: null },
{ id: 11, name: 'Alder', slug: 'alder', parent_id: 10 },
];
assert.deepEqual(
speciesLeavesFromTaxonomy(alt).map((l) => l.slug),
['alder']
);
});
});
@@ -0,0 +1,27 @@
/** Species leaf selection for SETTINGS-35 rate rows.
Resolves the Species grouper by slug `species` (name fallback "Species"), then
returns its children. Never hardcodes store category ids (QUALITY-105 / METADATA-5).
*/
export interface TaxonomyCategory {
id: number;
name: string;
slug: string;
parent_id: number | null;
}
export function speciesLeavesFromTaxonomy(categories: TaxonomyCategory[]): TaxonomyCategory[] {
if (!categories.length) return [];
const grouper =
categories.find((c) => (c.slug || '').toLowerCase() === 'species') ||
categories.find((c) => (c.name || '').trim().toLowerCase() === 'species');
if (!grouper) return [];
return categories
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
.slice()
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
@@ -0,0 +1,210 @@
import { test, expect, type Page, type Route } from '@playwright/test';
/**
* SETTINGS-35 UI contract against vite preview (no /api proxy).
* Taxonomy/pricing are Playwright mocks with fixture ids only — never live UAT
* store ids (QUALITY-105 / METADATA-5).
*/
const SETTINGS = {
woo_base_url: 'https://example.invalid',
woo_wp_username: 'wpuser',
woo_app_password: { configured: true },
inference_base_url: { configured: false },
inference_api_key: { configured: false },
inference_model: null,
inference_enabled: false,
content_llm_enabled: false,
brand_voice: null,
geo_context: null,
woo_create_status: 'draft',
};
/** Synthetic parented tree. Fixture ids 10/11/12/20/21/99 — not production. */
const PARENTED_TAXONOMY = {
categories: [
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
{ id: 11, name: 'Cedar', slug: 'cedar', parent_id: 10 },
{ id: 12, name: 'Maple', slug: 'maple', parent_id: 10 },
{ id: 20, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: 21, name: 'Live edge wood', slug: 'liveedge', parent_id: 20 },
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
],
attributes: [],
tags: [],
taxonomy_anchor: 'fixture',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
};
async function json(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
type ApiOpts = {
taxonomy?: unknown;
pricingGet?: () => unknown;
onPricingPost?: (route: Route, payload: Record<string, unknown>) => Promise<void>;
onPricingPut?: (route: Route, id: string, payload: Record<string, unknown>) => Promise<void>;
};
async function mockApi(page: Page, opts: ApiOpts) {
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
const payload = (() => {
try {
return req.postDataJSON() as Record<string, unknown>;
} catch {
return {};
}
})();
if (path === '/api/v1/settings' && method === 'GET') {
await json(route, SETTINGS);
return;
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
await json(route, opts.taxonomy ?? { categories: [] });
return;
}
const putMatch = path.match(/^\/api\/v1\/admin\/pricing\/(\d+)$/);
if (putMatch && method === 'PUT') {
if (opts.onPricingPut) {
await opts.onPricingPut(route, putMatch[1], payload);
return;
}
await json(route, { error: { code: 'not_found', message: 'rule not found' } }, 404);
return;
}
if (path === '/api/v1/admin/pricing' && method === 'GET') {
const list = opts.pricingGet ? opts.pricingGet() : [];
await json(route, list);
return;
}
if (path === '/api/v1/admin/pricing' && method === 'POST') {
if (opts.onPricingPost) {
await opts.onPricingPost(route, payload);
return;
}
await json(
route,
{
id: 601,
species_id: payload.species_id,
price_per_bdft: payload.price_per_bdft,
},
201
);
return;
}
await json(route, { error: { code: 'not_found', message: path } }, 404);
});
}
async function openSettings(page: Page) {
await page.goto('/');
await page.getByRole('button', { name: 'Settings (dev)' }).click();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByTestId('species-pricing')).toBeVisible();
}
test.describe('Settings species pricing (SETTINGS-35)', () => {
test('empty taxonomy: prompt only, no seeded species rows', async ({ page }) => {
await mockApi(page, { taxonomy: { categories: [] } });
await openSettings(page);
const empty = page.getByTestId('species-pricing-empty');
await expect(empty).toBeVisible();
await expect(empty).toContainText(/Test WooCommerce/i);
await expect(page.locator('.rate-row')).toHaveCount(0);
await expect(page.getByTestId('save-rates')).toHaveCount(0);
await expect(page.getByText('Cedar', { exact: true })).toHaveCount(0);
await expect(page.getByText('Maple', { exact: true })).toHaveCount(0);
});
test('lists only Species-grouper children (not wood cats / parents / furniture)', async ({
page,
}) => {
await mockApi(page, {
taxonomy: PARENTED_TAXONOMY,
pricingGet: () => [{ id: 501, species_id: 11, price_per_bdft: 8.25 }],
});
await openSettings(page);
await expect(page.locator('.rate-row')).toHaveCount(2);
await expect(page.locator('[data-species-slug="cedar"]')).toBeVisible();
await expect(page.locator('[data-species-slug="maple"]')).toBeVisible();
await expect(page.getByLabel('Price per bdft for Cedar')).toHaveValue('8.25');
await expect(page.getByLabel('Price per bdft for Maple')).toHaveValue('');
await expect(page.locator('[data-species-slug="liveedge"]')).toHaveCount(0);
await expect(page.locator('[data-species-slug="chairs"]')).toHaveCount(0);
await expect(page.locator('[data-species-slug="species"]')).toHaveCount(0);
await expect(page.locator('[data-species-slug="wcat"]')).toHaveCount(0);
await expect(page.getByText('Live edge wood')).toHaveCount(0);
await expect(page.getByText('Chairs', { exact: true })).toHaveCount(0);
});
test('Save rates POSTs a new rule', async ({ page }) => {
const posts: Record<string, unknown>[] = [];
await mockApi(page, {
taxonomy: PARENTED_TAXONOMY,
onPricingPost: async (route, payload) => {
posts.push(payload);
await json(
route,
{ id: 601, species_id: payload.species_id, price_per_bdft: payload.price_per_bdft },
201
);
},
});
await openSettings(page);
await page.getByLabel('Price per bdft for Cedar').fill('12.5');
await page.getByTestId('save-rates').click();
await expect(page.getByText('Saved 1 rate')).toBeVisible();
expect(posts).toEqual([{ species_id: 11, price_per_bdft: 12.5 }]);
});
test('Save rates PUT after POST 409', async ({ page }) => {
const posts: Record<string, unknown>[] = [];
const puts: { id: string; payload: Record<string, unknown> }[] = [];
let sawConflict = false;
await mockApi(page, {
taxonomy: PARENTED_TAXONOMY,
pricingGet: () =>
sawConflict ? [{ id: 501, species_id: 12, price_per_bdft: 8 }] : [],
onPricingPost: async (route, payload) => {
posts.push(payload);
sawConflict = true;
await json(
route,
{
error: {
code: 'duplicate',
message: 'rule exists for species',
detail: { species_id: payload.species_id },
},
},
409
);
},
onPricingPut: async (route, id, payload) => {
puts.push({ id, payload });
await json(route, {
id: Number(id),
species_id: 12,
price_per_bdft: payload.price_per_bdft,
});
},
});
await openSettings(page);
await page.getByLabel('Price per bdft for Maple').fill('9.75');
await page.getByTestId('save-rates').click();
await expect(page.getByText('Saved 1 rate')).toBeVisible();
expect(posts).toEqual([{ species_id: 12, price_per_bdft: 9.75 }]);
expect(puts).toEqual([{ id: '501', payload: { price_per_bdft: 9.75 } }]);
});
});