diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..95d4bbf --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,45 @@ +# CONTEXT.md — Kehityskonteksti (torium) + +Fork projektista `ahnl/tori-client`. Tori.fi:n epävirallinen API-client (kirjasto + CLI + MCP-serveri), joka jäljittelee iOS-sovelluksen liikennettä (finn-gw-allekirjoitukset, apps-adinput-subdomain). + +## Nykytila + +- **Toimii:** haku, omat ilmoitukset, viestit, suosikit, create_listing (kuvineen), republish_listing, dispose/delete. +- **Korjattu ja live-vahvistettu 2026-07-13:** edit_listing / listings.edit() — hiljainen tietohäviö korjattu (ks. alla). Testattu oikeaa APIa vasten: 12265237 (EXPIRED → hinta 30 € + republish onnistui), 20701613 (kuvaus meni liveksi). Paikallinen MCP-serveri pitää käynnistää uudelleen ennen kuin korjaus on MCP-työkaluissa käytössä (editable install; lisäksi CLI-testit rotatoivat refresh-tokenin, jonka vanha serveriprosessi pitää muistissa). +- **Kesken käyttäjän datassa:** 62 EXPIRED-ilmoitusta odottaa julkaisua. HUOM: 11.7. tehdyt tekstipäivitykset ovat tallessa niiden adinput-luonnosrevisioissa — commit (publish) riittää viemään ne liveksi, tekstejä ei tarvitse lähettää uudelleen. SpineGymin (12265237) live-kuvauksessa lukee yhä "Nyt 75 €" vaikka hintakenttä on 30 € — kuvausteksti pitää päivittää. + +## Bugit ja korjaushistoria + +| Bugi | Juurisyy | Korjaus | +|---|---|---| +| edit_listing kuittasi onnistuneeksi, mutta muutos ei tallentunut (silent write loss) | Adinput-API on kaksivaiheinen: `PUT .../update` tallentaa vain luonnosrevision; elävä ilmoitus päivittyy vasta commit-vaiheessa (`GET productcontext?adRevision` → `POST /order/choices`). Edit-polku pysähtyi PUT:iin ja päätteli onnistumisen pelkästä ETagista. | `listings.edit()`: update → publish (sama sekvenssi kuin create():ssa) → read-back-varmistus adview'sta. Lisäksi PUT-vastauksen `meta-data.violations` tarkistetaan (API palauttaa validointivirheet 200-statuksella). Testit: `tests/test_edit_flow.py`. | +| Uusi ilmoitus jäi jumiin "tarkistettavana"-tilaan | Publish ilman delivery-asetuksia / productcontext-hakua | Commitit `2eba60f`, `62637eb`: set_delivery + productcontext ennen order/choices-kutsua | + +## Arkkitehtuuripäätökset + +- **edit() ei kutsu set_delivery():ä** (toisin kuin create()): julkaistulla ilmoituksella toimitusasetukset ovat jo olemassa, eikä niitä haluta ylikirjoittaa oletuksilla. +- **Commit tehdään `POST /order/choices` -kutsulla (Basic, ilmainen)** — live-vahvistettu 2026-07-13 sekä EXPIRED- että editointitapauksessa. withModel-vastauksen `checkout-url` (`publish_free_ad?adRevision=...`) palauttaa POST:lle 404 — sitä EI käytetä. Huom: editissä `productcontext.choices` on tyhjä (ei ostettavaa), mutta order/choices committoi revision silti. +- **`edited`-aikaleima leimautuu revision LUONTIhetkestä, ei julkaisuhetkestä.** Älä käytä sitä "julkaistiinko juuri nyt" -tarkistukseen; vertaa kenttäarvoja. +- **Adview'n propagaatio kestää minuutteja** commitin jälkeen (mitattu: muutos näkyi vasta ~2–10 min). Read-back-turvaverkko odottaa porrastetusti ~5,5 min (`_READBACK_DELAYS`). Jos aikakatkaisu tulee, virheviesti kertoo että muutos voi silti vielä propagoitua. +- **Read-back-varmistus on pysyvä turvaverkko**, ei väliaikainen: edit() vertaa adview-kenttiä whitespace-normalisoituna. Jos kentät eivät muuttuneet → RuntimeError, ei koskaan valheellista onnistumista. +- **update() heittää virheen validointirikkeistä** (`meta-data.violations`, tulevat 200-statuksella) mutta ei julkaise — julkaisu vain edit():n kautta, jotta create():n oma sekvenssi ei riko. + +## Tunnetut riskit + +- **Matala:** read-backin aikakatkaisu (~5,5 min) voi antaa vääriä hälytyksiä jos Torin propagaatio on poikkeuksellisen hidas — mutta ei koskaan väärää onnistumiskuittausta. Tarkista `get_listing`illä ennen uudelleenyritystä. +- **Matala/huomio:** EXPIRED-ilmoituksen editointi tekee samalla republishin (sama commit-endpoint) — massa-ajossa yksi edit-kutsu hoitaa molemmat. +- **Matala:** rinnakkaiset clientit samalla refresh-tokenilla pudottavat autentikoinnin (token rotation; pitkään ajossa oleva prosessi pitää tokenin muistissa eikä lue tiedostoa uudelleen). Älä aja CLI:tä/probeja kun paikallinen MCP-serveri on käynnissä — ja MCP-serverin restart korjaa tilanteen, koska se lukee credentials.json:n tuoreena. + +## Seuraavat askeleet + +1. Käynnistä paikallinen MCP-serveri uudelleen (lataa korjatun koodin JA tuoreen tokenin — CLI-testit 13.7. rotatoivat sen). +2. Massa-ajo 62 EXPIRED-ilmoitukselle: 11.7. tekstipäivitykset ovat jo luonnosrevisioissa, joten `republish_listing` committoi ne samalla — TAI aja `edit_listing` uusilla teksteillä (varmistetumpi: read-back todistaa). SpineGymin kuvausteksti pitää joka tapauksessa päivittää ("Nyt 75 €" → 30 €). +3. Tarkista massa-ajon jälkeen pistokokein pari ilmoitusta `get_listing`illä (muista ~minuuttien propagaatio). + +## Ympäristötiedot + +- Koodi: `C:\Users\nmlus\Documents\asklepios\Claude\koodaus\torium` (git, fork `ahnl/tori-client`) +- Venv: `.venv\Scripts\python.exe`, asennettu editable-tilassa (`pip install -e .`) +- Testit: `.venv\Scripts\python.exe -m unittest tests.test_edit_flow -v` (ei verkkoa, mock-client) +- MCP-serveri: `torium-mcp` (stdio) tai HTTP-tila (docker-compose.yml); `build/lib/`-hakemisto on vanhentunut build-artefakti, ei käytössä +- Probe-JSONit (`_probe*.json`) ovat toukokuun 2026 withModel/adview-vastauksia — hyödyllisiä API-rakenteen tarkistukseen ilman live-kutsuja diff --git a/README.md b/README.md index 6aea749..fb6aa57 100644 --- a/README.md +++ b/README.md @@ -275,10 +275,12 @@ client.listings.create("Title", "Desc", price=10, category="193", postal_code="9 client.listings.set_delivery(12345, shipping=True, package_size="LARGE", city="Helsinki", postal_code="00100") # change delivery options client.listings.republish(12345) # republish an expired listing -client.listings.set_price(12345, 7) # change price directly -values, etag = client.listings.get_for_edit(12345) # fetch for editing -values["title"] = "New title" -client.listings.update(12345, values, etag) # submit full update +client.listings.set_price(12345, 7) # change price (publishes + verifies) +client.listings.edit(12345, title="New title", description="...") +# edit() runs the full flow: update draft revision → publish → read-back +# verification. Raises RuntimeError if the change did not go live. +# NOTE: get_for_edit()/update() alone only store a draft revision — the live +# ad does NOT change without the publish step. Use edit() instead. # Messaging convs = client.messaging.list_conversations() diff --git a/tests/test_edit_flow.py b/tests/test_edit_flow.py new file mode 100644 index 0000000..fea1571 --- /dev/null +++ b/tests/test_edit_flow.py @@ -0,0 +1,135 @@ +""" +Regression tests for the edit flow (silent write loss bug). + +Bug: edit_listing submitted only the adinput draft revision (PUT .../update) +and reported success from the returned ETag. The live ad never changed because +the publish/commit step (productcontext + POST /order/choices) was missing, +and the response was never verified. + +These tests assert: + 1. edit() publishes after the update PUT (the missing commit step). + 2. edit() raises if the read-back shows the fields did not change. + 3. edit() raises if the update response contains validation violations, + and does NOT publish in that case. + +Run: python -m unittest tests.test_edit_flow -v +""" + +import unittest +from unittest.mock import patch + +from torium.listings import ListingsAPI + + +class FakeClient: + """Records calls; simulates adinput + adview endpoints.""" + + def __init__(self, adview_ad: dict, put_response: dict | None = None): + self.calls: list[tuple] = [] + self.adview_ad = adview_ad + self.put_response = put_response if put_response is not None else {"etag": 'W/"2"'} + + def adinput_get(self, path): + self.calls.append(("adinput_get", path)) + if "withModel" in path: + values = { + "title": "Vanha otsikko", + "description": "Vanha kuvaus", + "price": [{"price_amount": "75"}], + } + return {"ad": {"values": values}}, 'W/"1045902423"' + return {}, "" # productcontext + + def adinput_put(self, path, json_body, etag): + self.calls.append(("adinput_put", path, json_body, etag)) + return self.put_response + + def adinput_post(self, path, service="", body=b"", content_type=None): + self.calls.append(("adinput_post", path, body)) + return {"order-id": 1, "is-completed": True}, "", "" + + def get(self, path, service): + self.calls.append(("get", path, service)) + return {"ad": self.adview_ad, "meta": {}} + + def paths(self, kind): + return [c[1] for c in self.calls if c[0] == kind] + + +class EditFlowTest(unittest.TestCase): + def test_edit_publishes_after_update(self): + """The commit step must run — this was the root cause of the bug.""" + client = FakeClient( + adview_ad={"title": "Vanha otsikko", "description": "Vanha kuvaus", "price": 30} + ) + api = ListingsAPI(client) + + result = api.edit(12265237, price=30) + + put_paths = client.paths("adinput_put") + post_paths = client.paths("adinput_post") + self.assertEqual(put_paths, ["/adinput/ad/recommerce/12265237/update"]) + self.assertEqual(post_paths, ["/adinput/order/choices/12265237"]) + # productcontext must be fetched between update and publish + self.assertTrue( + any("productcontext" in p for p in client.paths("adinput_get")), + "productcontext was not fetched before publish", + ) + # publish must come after the update PUT + kinds = [c[0] for c in client.calls] + self.assertLess(kinds.index("adinput_put"), kinds.index("adinput_post")) + self.assertEqual(result["changed"], ["price"]) + + def test_edit_raises_when_readback_shows_no_change(self): + """Never report success when the live ad is unchanged (silent write loss).""" + client = FakeClient( + adview_ad={"title": "Vanha otsikko", "description": "Vanha kuvaus", "price": 75} + ) + api = ListingsAPI(client) + + with patch("torium.listings.time.sleep"): + with self.assertRaises(RuntimeError) as ctx: + api.edit(12265237, price=30) + self.assertIn("not visible in adview", str(ctx.exception)) + + def test_edit_raises_on_validation_violations_and_skips_publish(self): + """A 200 response with meta-data violations is a rejected update.""" + client = FakeClient( + adview_ad={"title": "Vanha otsikko", "description": "Vanha kuvaus", "price": 75}, + put_response={ + "ad": { + "meta-data": { + "violation-count": 1, + "title": {"violations": ["Pakollinen kenttä"], "label": "Otsikko"}, + } + } + }, + ) + api = ListingsAPI(client) + + with self.assertRaises(RuntimeError) as ctx: + api.edit(12265237, title="Uusi otsikko") + self.assertIn("validation", str(ctx.exception)) + self.assertEqual(client.paths("adinput_post"), [], "must not publish a rejected revision") + + def test_edit_requires_at_least_one_field(self): + api = ListingsAPI(FakeClient(adview_ad={})) + with self.assertRaises(ValueError): + api.edit(12265237) + + def test_readback_normalizes_whitespace(self): + """adview may normalize newlines/whitespace — that is not a mismatch.""" + client = FakeClient( + adview_ad={ + "title": "Vanha otsikko", + "description": "Uusi kuvaus\nrivillä kaksi", + "price": 75, + } + ) + api = ListingsAPI(client) + result = api.edit(12265237, description="Uusi kuvaus rivillä kaksi") + self.assertEqual(result["changed"], ["description"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/torium/cli.py b/torium/cli.py index 4841c54..e98ab79 100644 --- a/torium/cli.py +++ b/torium/cli.py @@ -242,10 +242,9 @@ def listings_edit( """Edit a listing (price, title, description).""" client = get_client() - with console.status(f"Fetching listing {ad_id}..."): - values, etag = client.listings.get_for_edit(ad_id) - if dry_run: + with console.status(f"Fetching listing {ad_id}..."): + values, etag = client.listings.get_for_edit(ad_id) rprint(f"[bold]Current values for {ad_id}[/bold] (ETag: {etag})") rprint(f" title: {values.get('title', '-')}") cur_price = values.get("price", [{}]) @@ -257,26 +256,20 @@ def listings_edit( rprint("[red]Specify at least one of --price, --title, --description (or --dry-run to inspect).[/red]") raise typer.Exit(1) - if price is not None: - values["price"] = [{"price_amount": str(price)}] - if title is not None: - values["title"] = title - if description is not None: - values["description"] = description - - with console.status(f"Updating listing {ad_id}..."): - result = client.listings.update(ad_id, values, etag) + with console.status(f"Updating listing {ad_id} (submit + publish + verify, may take minutes)..."): + try: + client.listings.edit(ad_id, price=price, title=title, description=description) + except RuntimeError as e: + rprint(f"[red]FAILED: {e}[/red]") + raise typer.Exit(1) - new_etag = result.get("etag", "") - rprint(f"[green]✓ Listing {ad_id} updated.[/green]") + rprint(f"[green]OK — listing {ad_id} updated, published and verified live.[/green]") if price is not None: rprint(f" price → {price} €") if title is not None: rprint(f" title → {title}") if description is not None: rprint(f" description updated") - if new_etag: - rprint(f" new ETag: {new_etag}") @listings_app.command("create") diff --git a/torium/listings.py b/torium/listings.py index 18922be..f9f92b8 100644 --- a/torium/listings.py +++ b/torium/listings.py @@ -30,6 +30,40 @@ _IMG_BASE = "https://img.tori.net/dynamic/default/" +# Publish as Basic (free): urn:product:package-specification:10 +_PUBLISH_BASIC_BODY = b"choices=urn%3Aproduct%3Apackage-specification%3A10" + +# Read-back retry schedule (seconds before each attempt). Adview propagation +# after the order/choices commit takes minutes, not seconds — verified live +# 2026-07-13: a committed price change appeared in adview only after ~2-10 min. +_READBACK_DELAYS = (0, 10, 15, 30, 30, 60, 60, 60, 60) + + +def _norm_field(v): + """Normalize a field value for read-back comparison (whitespace runs in strings).""" + if isinstance(v, str): + return " ".join(v.split()) + return v + + +def _raise_on_violations(resp: dict) -> None: + """ + The adinput update PUT can return 200 with validation violations in + meta-data (e.g. {"violation-count": 3, "title": {"violations": [...]}}). + A revision with violations never goes live, so treat it as an error. + """ + meta = resp.get("meta-data") or resp.get("ad", {}).get("meta-data") or {} + count = meta.get("violation-count", 0) + if count: + details = { + field: info["violations"] + for field, info in meta.items() + if isinstance(info, dict) and info.get("violations") + } + raise RuntimeError( + f"Update rejected by validation ({count} violations): {details}" + ) + def _image_dimensions(data: bytes) -> tuple[int, int]: """Return (width, height) by parsing JPEG or PNG file headers.""" @@ -211,11 +245,93 @@ def update(self, ad_id: int, values: dict, etag: str) -> dict: Submit a full ad update. values must be the complete field map (from get_for_edit), with any desired changes applied. + NOTE: this only stores a new adinput draft revision — the live ad does + NOT change until the publish step runs (see edit()). Raises RuntimeError + if the server reports validation violations in the response body. + Returns the response which includes the new ETag and action URLs. """ - return self._c.adinput_put( + result = self._c.adinput_put( f"/adinput/ad/recommerce/{ad_id}/update", values, etag ) + _raise_on_violations(result) + return result + + def _publish_basic(self, ad_id: int) -> dict: + """ + Commit the current adinput revision live as Basic (free). + + Same completion sequence create() uses: fresh withModel etag → + productcontext(adRevision) → POST /order/choices. Without this the + updated revision is never published (silent write loss). + """ + _, fresh_etag = self._c.adinput_get(f"/adinput/ad/withModel/{ad_id}") + ad_revision = re.sub(r"\D", "", fresh_etag) or fresh_etag + self._c.adinput_get( + f"/adinput/product/recommerce/{ad_id}/productcontext?adRevision={ad_revision}" + ) + publish_result, _, _ = self._c.adinput_post( + f"/adinput/order/choices/{ad_id}", + body=_PUBLISH_BASIC_BODY, + content_type="application/x-www-form-urlencoded", + ) + return publish_result + + def edit( + self, + ad_id: int, + *, + price: Optional[int] = None, + title: Optional[str] = None, + description: Optional[str] = None, + ) -> dict: + """ + Edit a listing's price, title, and/or description, publish the change, + and verify it actually went live. + + Flow: get_for_edit → update (draft revision) → publish (commit) → + read-back via adview. Raises RuntimeError if validation rejects the + update or if the read-back shows the fields did not change. + """ + if price is None and title is None and description is None: + raise ValueError("specify at least one of price, title, description") + + values, etag = self.get_for_edit(ad_id) + expected: dict = {} + if price is not None: + values["price"] = [{"price_amount": str(price)}] + expected["price"] = price + if title is not None: + values["title"] = title + expected["title"] = title + if description is not None: + values["description"] = description + expected["description"] = description + + self.update(ad_id, values, etag) + publish_result = self._publish_basic(ad_id) + + # Read-back: prove the change is live before reporting success. + mismatched: dict = {} + for delay in _READBACK_DELAYS: + if delay: + time.sleep(delay) + after = self.get(ad_id).get("ad", {}) + mismatched = { + k: after.get(k) + for k, v in expected.items() + if _norm_field(after.get(k)) != _norm_field(v) + } + if not mismatched: + break + if mismatched: + detail = {k: {"live": v, "expected": expected[k]} for k, v in mismatched.items()} + raise RuntimeError( + f"Update was submitted and published for ad {ad_id}, but the change " + f"was not visible in adview after ~5 min: {detail}. " + f"It may still propagate — re-check with get_listing before retrying." + ) + return {"ad_id": ad_id, "changed": sorted(expected), "publish": publish_result} def upload_images(self, ad_id: int, image_paths: list[str]) -> list[str]: """ @@ -425,10 +541,9 @@ def _wait_ready(loc: str) -> None: ) # Step 3: publish as Basic (free) - body = b"choices=urn%3Aproduct%3Apackage-specification%3A10" publish_result, _, _ = self._c.adinput_post( f"/adinput/order/choices/{ad_id}", - body=body, + body=_PUBLISH_BASIC_BODY, content_type="application/x-www-form-urlencoded", ) publish_result["ad_id"] = ad_id @@ -436,12 +551,10 @@ def _wait_ready(loc: str) -> None: def set_price(self, ad_id: int, price: int) -> dict: """ - Change the price on a listing. Fetches current values, updates price, - and submits. Returns the update response. + Change the price on a listing. Full edit flow with publish + read-back + verification (see edit()). """ - values, etag = self.get_for_edit(ad_id) - values["price"] = [{"price_amount": str(price)}] - return self.update(ad_id, values, etag) + return self.edit(ad_id, price=price) def republish(self, ad_id: int) -> dict: """ @@ -451,10 +564,9 @@ def republish(self, ad_id: int) -> dict: Returns the publish response: {"order-id": ..., "is-completed": True, ...} """ - body = b"choices=urn%3Aproduct%3Apackage-specification%3A10" result, _, _ = self._c.adinput_post( f"/adinput/order/choices/{ad_id}", - body=body, + body=_PUBLISH_BASIC_BODY, content_type="application/x-www-form-urlencoded", ) return result diff --git a/torium/mcp_server.py b/torium/mcp_server.py index 5bf1f6c..e2a3aa2 100644 --- a/torium/mcp_server.py +++ b/torium/mcp_server.py @@ -408,8 +408,9 @@ def edit_listing( ) -> str: """ Edit a listing's price, title, or description. Fetches current values from - the adinput service, applies the requested changes, and submits the update. - At least one of price/title/description must be provided. + the adinput service, applies the requested changes, submits the update, + publishes it live, and verifies via read-back that the change actually + took effect. At least one of price/title/description must be provided. ad_id: The listing ID to update. price: New price in euros. 0 = keep current price. @@ -420,23 +421,25 @@ def edit_listing( return "Error: specify at least one of price, title, or description." c = _get_client() - values, etag = c.listings.get_for_edit(ad_id) + try: + result = c.listings.edit( + ad_id, + price=price or None, + title=title or None, + description=description or None, + ) + except (RuntimeError, ValueError) as e: + return f"Error: {e}" changed = [] if price: - values["price"] = [{"price_amount": str(price)}] changed.append(f"price → {price} €") if title: - values["title"] = title changed.append(f"title → {title!r}") if description: - values["description"] = description changed.append("description updated") - - result = c.listings.update(ad_id, values, etag) - new_etag = result.get("etag", "") summary = ", ".join(changed) - return f"Listing {ad_id} updated: {summary}. New ETag: {new_etag}" + return f"Listing {ad_id} updated, published and verified live: {summary}." # ── Messaging ─────────────────────────────────────────────────────────────────