Skip to content
Open
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
45 changes: 45 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
135 changes: 135 additions & 0 deletions tests/test_edit_flow.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 9 additions & 16 deletions torium/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [{}])
Expand All @@ -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")
Expand Down
Loading