"""Woo client + taxonomy sync tests (mocked transport; never hit the real store).""" import base64 import json import os import shutil import tempfile import pytest import backend.db as db_mod from backend.woo import ( _USER_AGENT, WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooForbiddenError, WooServerError, ) from backend import taxonomy as taxonomy_mod @pytest.fixture def db_dir(monkeypatch): tmp = tempfile.mkdtemp(prefix="woo_") monkeypatch.setenv("SLAB_DB_PATH", os.path.join(tmp, "t.db")) monkeypatch.setenv("SLAB_IMAGES_DIR", tmp) monkeypatch.setenv("WOO_BASE_URL", "https://store.example.com") monkeypatch.setenv("SLAB_AES_KEY", base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()) db_mod.init_db() conn = db_mod.get_connection() from backend.crypto import encrypt conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','admin',NULL)") conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),)) conn.commit() yield conn shutil.rmtree(tmp, ignore_errors=True) def _client(responses): """Build a WooClient whose transport returns pre-canned (code, headers, body) per call.""" calls = {"n": 0} def fake_request(url, method, data, headers): i = min(calls["n"], len(responses) - 1) calls["n"] += 1 return responses[i] return WooClient("admin", "secret", "https://store.example.com", request=fake_request), calls def test_refuses_non_https(db_dir): with pytest.raises(WooCredentialsMissing): WooClient("admin", "secret", "http://insecure.example.com") def test_basic_auth_header_and_url(db_dir): seen = {} def fake_request(url, method, data, headers): seen["url"] = url seen["auth"] = headers["Authorization"] return 200, {"X-WP-TotalPages": "1"}, b"[]" c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) c.get_categories() assert seen["url"] == "https://store.example.com/wp-json/wc/v3/products/categories?per_page=100&page=1" expected = "Basic " + base64.b64encode(b"admin:secret").decode() assert seen["auth"] == expected def test_pagination_follows_total_pages(db_dir): p1 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":1}]') p2 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":2}]') c, calls = _client([p1, p2]) data = c.get_tags() assert [d["id"] for d in data] == [1, 2] assert calls["n"] == 2 def test_missing_password_fails_closed(db_dir): with pytest.raises(WooCredentialsMissing): WooClient.from_settings("https://store.example.com", "admin", None) def test_missing_username_fails_closed(db_dir): with pytest.raises(WooCredentialsMissing): WooClient.from_settings("https://store.example.com", None, "enc") def test_bad_ciphertext_fails_closed(db_dir): with pytest.raises(WooCredentialsMissing): WooClient.from_settings("https://store.example.com", "admin", "!!!not-base64!!!") def test_401_raises_auth_error(db_dir): def fake_request(url, method, data, headers): return 401, {}, b'{"message": "Invalid username"}' c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) with pytest.raises(WooAuthError): c.get_categories() def test_403_raises_forbidden_error(db_dir): def fake_request(url, method, data, headers): return 403, {}, b"{}" c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) with pytest.raises(WooForbiddenError): c.get_categories() def test_requests_include_user_agent(db_dir): seen_headers = {} def fake_request(url, method, data, headers): seen_headers.update(headers) return 200, {"X-WP-TotalPages": "1"}, b"[]" c = WooClient("admin", "secret", "https://store.example.com", request=fake_request) c.get_categories() assert seen_headers.get("User-Agent") == _USER_AGENT assert "SlabUploader" in seen_headers["User-Agent"] def test_sync_populates_scoped(db_dir): cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([ {"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}, {"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21}, {"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped ]).encode()) attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode()) terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode()) c, _ = _client([cats, tags, attrs, terms]) result = taxonomy_mod.sync(c, db_mod.get_connection()) conn = db_mod.get_connection() assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1] tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")} assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6] assert result["taxonomy_anchor"] assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0] def test_sync_failure_keeps_previous_cache(db_dir): conn = db_mod.get_connection() conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (99,'Old','old',NULL,1)") conn.commit() def bad_request(url, method, data, headers): raise WooServerError("boom") c = WooClient("admin", "secret", "https://store.example.com", request=bad_request) with pytest.raises(WooServerError): taxonomy_mod.sync(c, conn) # previous cache retained assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [99] def test_anchor_stable_across_identical_syncs(db_dir): cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}]).encode()) attrs = (200, {"X-WP-TotalPages": "1"}, b"[]") c1, _ = _client([cats, tags, attrs]) r1 = taxonomy_mod.sync(c1, db_mod.get_connection()) c2, _ = _client([cats, tags, attrs]) r2 = taxonomy_mod.sync(c2, db_mod.get_connection()) 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