diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index da893e0..243bff7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -74,12 +74,14 @@ {"_type":"issue","id":"FlatPilot-t2p3","title":"Test Epic","description":"parent","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-04-20T15:48:34Z","updated_at":"2026-04-20T15:48:53Z","closed_at":"2026-04-20T15:48:53Z","close_reason":"test graph schema","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-l27z","title":"Test","description":"test","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T15:48:12Z","updated_at":"2026-04-20T15:48:23Z","closed_at":"2026-04-20T15:48:23Z","close_reason":"test cleanup","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-997r","title":"Test","description":"test","status":"closed","priority":2,"issue_type":"task","created_at":"2026-04-20T15:47:25Z","updated_at":"2026-04-20T15:48:05Z","closed_at":"2026-04-20T15:48:05Z","close_reason":"test cleanup","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"FlatPilot-ko1","title":"Definitive long-term gating: WG-Gesucht category URL + detail-page enrichment","description":"Follow-up to the title-keyword short-term filter (cheap heuristic). For a definitive signal, fetch detail-page data so the existing `filter_contract` (filters.py:119-127) actually has `min_contract_months` to compare against.\n\nTwo parts:\n\nPART A — WG-Gesucht URL-level category gating (low risk, no detail fetch)\n- WG-Gesucht search URL is `/wohnungen-in-\u003cslug\u003e.\u003ccity_id\u003e.2.1.0.html` (src/flatpilot/scrapers/wg_gesucht.py:146).\n- The `2` is the \"Wohnung / full flat\" category. WG-Gesucht has a separate URL space for \"Befristete WG / Zwischenmiete\". Verify in `scripts/wg_probe.py` whether the current URL already excludes Zwischenmiete or if there's a finer-grained flag (e.g. `rent_type=long_term`) we should add.\n- If the current URL already filters this out: document it in the scraper docstring and close part A.\n- If not: add the appropriate query param or path segment, gated by `profile.exclude_short_term`.\n\nPART B — detail-page enrichment for matched flats only\n- For flats that pass all cheap filters, fetch the detail page (politely, with the existing session machinery).\n- Parse the explicit fields:\n - WG-Gesucht: `Mindestmietdauer`, `Befristet: Ja/Nein`, `Verfügbar bis`\n - Kleinanzeigen: `Mietdauer`, the \"Befristet\" toggle if present\n- Populate `flat[\"min_contract_months\"]` so the existing `filter_contract` filter actually fires.\n- Cap the per-run detail-fetch budget (e.g. 30 flats/run) to keep latency and politeness in check.\n- This is the same enrichment the wg_gesucht.py:8 docstring already flags as a future pass.\n\nAcceptance criteria:\n- Part A: a probe run confirms long-term-only URL behavior OR a URL change is shipped + tested.\n- Part B: at least one new test asserts that a detail page with \"Mindestmietdauer: 24 Monate\" populates `min_contract_months=24` on the flat row.\n- New flat field `available_until` populated when present (for the date-range short-term heuristic in the keyword-filter issue to cross-check).\n- Detail fetch failure must not poison the flat — fall back to the keyword filter from the sibling issue.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:20:21Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T09:59:45Z","started_at":"2026-05-19T09:59:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"FlatPilot-569","title":"ic1 Phase 2: iterate kleinanzeigen contact form and fill structured fields from profile.contact_details","description":"Follow-up to bd-ic1 Phase 1 (PR shipping the schema + wizard + Filler protocol thread-through). Phase 2 is the actual filler change.\n\nScope:\n- After _reveal_contact_form, iterate every visible input/select/textarea under form#viewad-contact-form.\n- For each field, read its name / id / associated \u003clabel for=...\u003e text.\n- Match against a configurable label-keyword map (Anrede, Vorname, Nachname, Telefon, Straße, PLZ, SCHUFA, Haushalt, ...) → profile.contact_details attribute.\n- Fill the field if it's empty and the profile defines a value. Skip otherwise. For \u003cselect\u003e, use select_option().\n- Add fixture HTML for the 15-field variant and the message-only variant (capture from a real listing).\n- Tests assert each variant is filled correctly and that fields not in the profile stay untouched.\n\nPrerequisite: empirical capture of real kleinanzeigen contact-form HTML for both variants. Without that, the label keywords and selector strategy are guesses.","status":"open","priority":3,"issue_type":"feature","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-19T13:42:03Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T13:42:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"FlatPilot-k1v","title":"ko1 Part B: WG-Gesucht/Kleinanzeigen detail-page enrichment for min_contract_months","description":"Follow-up to bd-ko1 Part A (URL gating shipped via PR #63). Part B was scoped out of that PR to keep the diff small.\n\nWhat's still needed (verbatim from the original bd-ko1):\n\nFor flats that pass all cheap filters, fetch the detail page (politely, with the existing session machinery) and parse the explicit fields:\n- WG-Gesucht: Mindestmietdauer, Befristet: Ja/Nein, Verfügbar bis\n- Kleinanzeigen: Mietdauer, the 'Befristet' toggle if present\n\nPopulate flat['min_contract_months'] so the existing filter_contract (filters.py:119-127) actually fires. Cap the per-run detail-fetch budget (e.g. 30 flats/run) to keep latency and politeness in check.\n\nAcceptance:\n- At least one new test asserts that a detail page with 'Mindestmietdauer: 24 Monate' populates min_contract_months=24 on the flat row.\n- New flat field available_until populated when present (cross-checks the date-range short-term heuristic from bd-33h).\n- Detail fetch failure must not poison the flat — fall back to the bd-33h keyword filter.","status":"open","priority":3,"issue_type":"feature","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-19T10:03:16Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T10:03:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"FlatPilot-ko1","title":"Definitive long-term gating: WG-Gesucht category URL + detail-page enrichment","description":"Follow-up to the title-keyword short-term filter (cheap heuristic). For a definitive signal, fetch detail-page data so the existing `filter_contract` (filters.py:119-127) actually has `min_contract_months` to compare against.\n\nTwo parts:\n\nPART A — WG-Gesucht URL-level category gating (low risk, no detail fetch)\n- WG-Gesucht search URL is `/wohnungen-in-\u003cslug\u003e.\u003ccity_id\u003e.2.1.0.html` (src/flatpilot/scrapers/wg_gesucht.py:146).\n- The `2` is the \"Wohnung / full flat\" category. WG-Gesucht has a separate URL space for \"Befristete WG / Zwischenmiete\". Verify in `scripts/wg_probe.py` whether the current URL already excludes Zwischenmiete or if there's a finer-grained flag (e.g. `rent_type=long_term`) we should add.\n- If the current URL already filters this out: document it in the scraper docstring and close part A.\n- If not: add the appropriate query param or path segment, gated by `profile.exclude_short_term`.\n\nPART B — detail-page enrichment for matched flats only\n- For flats that pass all cheap filters, fetch the detail page (politely, with the existing session machinery).\n- Parse the explicit fields:\n - WG-Gesucht: `Mindestmietdauer`, `Befristet: Ja/Nein`, `Verfügbar bis`\n - Kleinanzeigen: `Mietdauer`, the \"Befristet\" toggle if present\n- Populate `flat[\"min_contract_months\"]` so the existing `filter_contract` filter actually fires.\n- Cap the per-run detail-fetch budget (e.g. 30 flats/run) to keep latency and politeness in check.\n- This is the same enrichment the wg_gesucht.py:8 docstring already flags as a future pass.\n\nAcceptance criteria:\n- Part A: a probe run confirms long-term-only URL behavior OR a URL change is shipped + tested.\n- Part B: at least one new test asserts that a detail page with \"Mindestmietdauer: 24 Monate\" populates `min_contract_months=24` on the flat row.\n- New flat field `available_until` populated when present (for the date-range short-term heuristic in the keyword-filter issue to cross-check).\n- Detail fetch failure must not poison the flat — fall back to the keyword filter from the sibling issue.","status":"closed","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:20:21Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T10:03:28Z","started_at":"2026-05-19T09:59:45Z","closed_at":"2026-05-19T10:03:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-ykg","title":"Log matcher reject reasons at DEBUG with external_id for audit","description":"Matcher rejections are persisted to `matches.decision_reasons_json` but never logged. `flatpilot doctor` shows no per-flat reason; users have to query SQLite to figure out why a specific flat was dropped.\n\nEvidence:\n- src/flatpilot/matcher/filters.py — every reject reason (rent_too_high, rooms_unknown, district_unknown, location_unknown, no_pets_allowed, move_in_too_late, etc.) returns a string but no log line.\n- src/flatpilot/matcher/runner.py:76-80 — the decision is computed and persisted but not surfaced.\n\nAcceptance criteria:\n- Single `logger.debug` line per flat with `external_id`, `platform`, decision, and `reasons`.\n- Goes through the existing `flatpilot.log` config — `--verbose` or `FLATPILOT_LOG_LEVEL=DEBUG` switches it on.\n- No new dependency, no perf hit at INFO level.\n- Optional follow-up: `flatpilot doctor --explain \u003cexternal_id\u003e` to print the same info from DB without needing logs replayed.","status":"closed","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:03:18Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T09:47:49Z","started_at":"2026-05-16T20:20:09Z","closed_at":"2026-05-19T09:47:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-gaj","title":"Continue saved-search loop in _try_flat when first match is auto_skipped","description":"`_try_flat()` returns on the first saved-search match — including when the result is `auto_skipped:` (filler missing / template error / attachment error). A different saved-search overlay on the same flat could have been completely fine, but never gets evaluated.\n\nEvidence:\n- src/flatpilot/auto_apply.py:427 — `for name in candidate_names:` loop over saved searches.\n- src/flatpilot/auto_apply.py:503 — completeness fail returns out of the whole `_try_flat()`.\n- src/flatpilot/auto_apply.py:518 — successful submit returns (correct).\n- src/flatpilot/auto_apply.py:523 — FillError returns (debatable; safer to also stop here).\n\nAcceptance criteria:\n- Distinguish \"terminal outcome for this flat\" (submitted, FillError on real submit) from \"this saved-search wasn't compatible\" (auto_skipped) and only return on the former.\n- Add a test: flat that auto_skips for saved-search A but is fully applicable under saved-search B → submits exactly once under B.\n- Verify cap and cooldown logic still work correctly when the loop continues (the cap check happens per-iteration, which is fine).","status":"closed","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:03:12Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T09:51:33Z","started_at":"2026-05-19T09:48:09Z","closed_at":"2026-05-19T09:51:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"FlatPilot-m6g","title":"Scope known_external_ids per-user in pipeline (multi-user safety)","description":"Today the \"seen\" set queried in pipeline.py is per-platform, not per-user. In single-user mode this is fine. In multi-user mode (Phase 5+ web UI), user A's scrape can mark a flat as seen via `INSERT OR IGNORE` and user B will never get a match row evaluated for it.\n\nEvidence:\n- src/flatpilot/pipeline.py:155-161:\n `SELECT external_id FROM flats WHERE platform = ?`\n- src/flatpilot/schemas.py:57 — `UNIQUE (platform, external_id)` is platform-scoped, not user-scoped (correct for the flat table itself — flat content is shared).\n- Matcher decisions ARE per-user (matches table has `user_id`), so the fix is at the scraper-skip layer only.\n\nAcceptance criteria:\n- `known_external_ids` for a given user/profile = external_ids the user *has already had a decision made on* (matches.user_id = ?), not just every external_id in flats.\n- This lets a brand-new user re-evaluate flats already scraped by others.\n- Tests: with two users, ensure a flat scraped under user A's run produces a match row for user B after their next pass.\n- Performance: stay O(N_flats_for_user) — index on `matches(user_id, external_id)` if needed.","status":"open","priority":3,"issue_type":"bug","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:03:02Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-15T18:03:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"FlatPilot-m6g","title":"Scope known_external_ids per-user in pipeline (multi-user safety)","description":"Today the \"seen\" set queried in pipeline.py is per-platform, not per-user. In single-user mode this is fine. In multi-user mode (Phase 5+ web UI), user A's scrape can mark a flat as seen via `INSERT OR IGNORE` and user B will never get a match row evaluated for it.\n\nEvidence:\n- src/flatpilot/pipeline.py:155-161:\n `SELECT external_id FROM flats WHERE platform = ?`\n- src/flatpilot/schemas.py:57 — `UNIQUE (platform, external_id)` is platform-scoped, not user-scoped (correct for the flat table itself — flat content is shared).\n- Matcher decisions ARE per-user (matches table has `user_id`), so the fix is at the scraper-skip layer only.\n\nAcceptance criteria:\n- `known_external_ids` for a given user/profile = external_ids the user *has already had a decision made on* (matches.user_id = ?), not just every external_id in flats.\n- This lets a brand-new user re-evaluate flats already scraped by others.\n- Tests: with two users, ensure a flat scraped under user A's run produces a match row for user B after their next pass.\n- Performance: stay O(N_flats_for_user) — index on `matches(user_id, external_id)` if needed.","status":"closed","priority":3,"issue_type":"bug","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:03:02Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T11:30:47Z","started_at":"2026-05-19T10:03:48Z","closed_at":"2026-05-19T11:30:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-ja7","title":"Surface auto_skipped flats in doctor/dashboard so user understands why apply 'stops'","description":"Flats recorded with `notes LIKE 'auto_skipped:%'` (filler not registered, template error, attachment error, listing_expired) are correctly excluded from cap counting and lifetime-deduped — but they sit silently in the DB. The user perceives this as \"auto-apply stops before cap\" when actually a large portion of matched flats can never be applied to.\n\nEvidence:\n- src/flatpilot/auto_apply.py:300-310 records the `auto_skipped:` rows.\n- Commit 5f556fa added lifetime TTL exclusion so these are processed once.\n- Currently no UI/CLI affordance to see the totals.\n\nAcceptance criteria:\n- `flatpilot doctor` (or `flatpilot status`) prints a per-reason breakdown:\n - `auto_skipped: filler not registered` (per platform)\n - `auto_skipped: template`\n - `auto_skipped: attachment`\n - `auto_skipped: listing_expired`\n- Same totals surface on the HTML dashboard (src/flatpilot/view.py).\n- Numbers update whenever the user closes a gap (e.g. adds a missing filler) — verify a follow-up run reduces the count.","status":"closed","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-15T18:02:54Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T09:58:05Z","started_at":"2026-05-19T09:51:48Z","closed_at":"2026-05-19T09:58:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"FlatPilot-ic1","title":"kleinanzeigen filler: fill landlord-required structured fields (Anrede, Vorname, etc.)","description":"The kleinanzeigen contact form has up to 15 fields (Anrede, Vorname, Nachname, Telefon, Straße/Nr., PLZ, SCHUFA-Auskunft, Haushaltstyp, Arbeitsverhältnis, …). Each landlord configures which fields they want filled. The filler currently fills only the message; on listings that mark structured fields as required, the submit either silently ignores us or fails inline validation in a way that fires neither the success nor the error banner.\n\nSymptom: 'kleinanzeigen: neither success nor error indicator appeared within 7000ms after submit' (now 15s, see FlatPilot-8kt's followup PR). Observed on flat 590 'Terrassenwohnung Berlin-Dahlem'. Sister flat 20 succeeded with the same filler — different landlords request different fields.\n\n## Plan\n\n1. Extend Profile schema with the kleinanzeigen-specific fields:\n - anrede: Literal['frau', 'herr', 'divers', 'keine_angabe'] (or use existing 'gender' if added)\n - given_name, surname (split from a single 'name' OR use 'household_contact_name')\n - phone (optional)\n - address: street, plz, city (already have city; add street + plz)\n - schufa_status: enum\n - household_type: enum\n - employment_status (already have 'status', map kleinanzeigen-specific labels to it)\n\n2. Wizard: prompt for these on `flatpilot init` (and `flatpilot init --reset`).\n\n3. Filler: after _reveal_contact_form, iterate visible fields under form#viewad-contact-form and fill each from the profile if a match exists. Use Playwright's locator(label='Anrede') / select_option for dropdowns. Skip optional fields the profile doesn't define.\n\n4. Tests: fixture HTML for the 15-field variant; fixture HTML for the message-only variant; assert each is filled correctly.\n\n## Out of scope\n\n- wg-gesucht does not use structured fields — this is kleinanzeigen-only for now.\n- inberlinwohnen has no filler at all (FlatPilot-mrt — separate decision).\n\n## Why deferred\n\nThe followup PR after FlatPilot-8kt adds failure-screenshot capture + bumps the success-detection timeout to 15s. That gives diagnostic reach AND fixes the failure case where kleinanzeigen XHR was slow but the submit DID complete. If the timeout fix alone makes flat 590-style failures go away, this bead drops priority. If failures persist with the longer timeout AND the screenshot shows visible inline-validation errors on un-filled fields, this becomes the next blocking work.","status":"open","priority":3,"issue_type":"feature","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-09T22:18:34Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-09T22:18:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"FlatPilot-ic1","title":"kleinanzeigen filler: fill landlord-required structured fields (Anrede, Vorname, etc.)","description":"The kleinanzeigen contact form has up to 15 fields (Anrede, Vorname, Nachname, Telefon, Straße/Nr., PLZ, SCHUFA-Auskunft, Haushaltstyp, Arbeitsverhältnis, …). Each landlord configures which fields they want filled. The filler currently fills only the message; on listings that mark structured fields as required, the submit either silently ignores us or fails inline validation in a way that fires neither the success nor the error banner.\n\nSymptom: 'kleinanzeigen: neither success nor error indicator appeared within 7000ms after submit' (now 15s, see FlatPilot-8kt's followup PR). Observed on flat 590 'Terrassenwohnung Berlin-Dahlem'. Sister flat 20 succeeded with the same filler — different landlords request different fields.\n\n## Plan\n\n1. Extend Profile schema with the kleinanzeigen-specific fields:\n - anrede: Literal['frau', 'herr', 'divers', 'keine_angabe'] (or use existing 'gender' if added)\n - given_name, surname (split from a single 'name' OR use 'household_contact_name')\n - phone (optional)\n - address: street, plz, city (already have city; add street + plz)\n - schufa_status: enum\n - household_type: enum\n - employment_status (already have 'status', map kleinanzeigen-specific labels to it)\n\n2. Wizard: prompt for these on `flatpilot init` (and `flatpilot init --reset`).\n\n3. Filler: after _reveal_contact_form, iterate visible fields under form#viewad-contact-form and fill each from the profile if a match exists. Use Playwright's locator(label='Anrede') / select_option for dropdowns. Skip optional fields the profile doesn't define.\n\n4. Tests: fixture HTML for the 15-field variant; fixture HTML for the message-only variant; assert each is filled correctly.\n\n## Out of scope\n\n- wg-gesucht does not use structured fields — this is kleinanzeigen-only for now.\n- inberlinwohnen has no filler at all (FlatPilot-mrt — separate decision).\n\n## Why deferred\n\nThe followup PR after FlatPilot-8kt adds failure-screenshot capture + bumps the success-detection timeout to 15s. That gives diagnostic reach AND fixes the failure case where kleinanzeigen XHR was slow but the submit DID complete. If the timeout fix alone makes flat 590-style failures go away, this bead drops priority. If failures persist with the longer timeout AND the screenshot shows visible inline-validation errors on un-filled fields, this becomes the next blocking work.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-09T22:18:34Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-19T12:04:33Z","started_at":"2026-05-19T12:04:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-pns","title":"wg-gesucht filler: classify 'no contact CTA' as listing_expired (already-messaged listings)","description":"WG-Gesucht hides the 'Nachricht senden' contact CTA on a listing once the logged-in user has messaged it. The wg-gesucht filler currently raises a non-classified FormNotFoundError ('no contact CTA matching ...') in that case, which apply_to_flat records as status='failed' rather than status='failed' WITH notes='auto_skipped: listing_expired'.\n\nEffect:\n- The flat keeps getting retried by run_pipeline_apply on every pass until max_failures_per_flat (default 3) blocks it. Each retry costs a Playwright session, a polite_session ramp-up, and a row in the applications table — pure waste.\n- max_failures_per_flat is meant for genuine filler regressions (selector breakage, auth loops); using it to gate already-applied listings is a misuse that hides real failures.\n- Cooldown still fires after each 'failure', wasting cap headroom by spacing out retries that will never succeed.\n\n## Plan\n\nIn flatpilot/fillers/wg_gesucht.py, _reveal_contact_form (or wherever 'no contact CTA' is raised), wrap the FormNotFoundError raise so callers can detect 'CTA missing on a logged-in session' as ListingExpiredError instead. The existing FlatPilot-tgw machinery then:\n- Writes notes='auto_skipped: listing_expired (...)'\n- Excludes the flat from the apply queue for LISTING_EXPIRED_TTL (7 days)\n- Doesn't count toward max_failures_per_flat\n\n## Acceptance\n\n1. WGGesuchtFiller raises ListingExpiredError when the listing page renders without a 'Nachricht senden' CTA AND the page does not show the login wall (LOGIN_URL_FRAGMENTS guard already handles that case).\n2. Unit test in tests/test_filler_submit.py covers both branches: CTA missing + logged in → ListingExpiredError; CTA missing + login wall → NotAuthenticatedError (current behaviour).\n3. Pipeline integration test verifies the 'auto_skipped: listing_expired' note is written and the flat is excluded from subsequent queue scans for 7 days.\n\n## Related\n- FlatPilot-8kt — the success-detection fix exposes this gap because real successes now correctly mark flats submitted=True; legacy false-fail rows from the pre-fix era keep poisoning the queue, so the 'auto-apply only finds dead listings' symptom is most visible right now.\n- FlatPilot-tgw — original listing-expired classification + TTL.","notes":"Closed 2026-05-14: already implemented by FlatPilot-tgw (commit 3fd5f08). src/flatpilot/fillers/wg_gesucht.py:267 raises ListingExpiredError when contact CTA is absent; unit test tests/test_filler_submit.py:363 covers the logged-in branch. The acceptance's 'CTA missing + login wall' sub-branch is structurally unreachable — listing URLs don't redirect to /login for logged-out users; only the contact-form URL does, caught by the second _guard_login() at wg_gesucht.py:279 before any CTA check runs.","status":"closed","priority":3,"issue_type":"task","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-09T21:41:40Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-14T10:30:16Z","started_at":"2026-05-14T10:24:50Z","closed_at":"2026-05-14T10:30:16Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-iwu","title":"flatpilot status: surface auto-apply runtime state (pause, caps, cooldowns)","description":"flatpilot status today reports DB counts and last-run info. It does not surface the runtime state of the auto-apply subsystem, so a user has no quick way to answer:\n\n- Is auto-apply paused? (~/.flatpilot/PAUSE present?)\n- How many of today's daily caps are used per platform? (daily_cap_per_platform vs. submitted today)\n- Is a per-platform cooldown active right now, and how many seconds remain?\n- How many flats are currently above max_failures_per_flat and being skipped?\n\nAll of these are already computable from helpers in flatpilot/auto_apply.py (is_paused, daily_cap_remaining, cooldown_remaining_sec, failures_for_flat) and the applications table — flatpilot status just doesn't display them.\n\n## Proposed surface\n\nExtend the `flatpilot status` Rich output with a new \"Auto-apply\" panel:\n\n Auto-apply\n State: ACTIVE (or \"PAUSED — ~/.flatpilot/PAUSE\")\n wg-gesucht 12 / 20 today · cooldown 0s\n kleinanzeigen 3 / 20 today · cooldown 47s remaining\n inberlinwohnen — (no filler registered)\n Flats over max_failures: 2\n\n## Acceptance\n\n1. `flatpilot status` shows the panel above with live values pulled from the same helpers used by the pipeline.\n2. PAUSED state is visually distinct (yellow) so it cannot be missed.\n3. Platforms without a registered filler are listed but marked as not auto-applying.\n4. New unit test in tests/ covers the panel rendering with a populated DB fixture (paused vs. active, cap exhausted, cooldown active, max-failure flats).\n\n## Out of scope\n\n- No new auto-apply behavior; this is a read-only display.\n- No web UI surface — CLI only (UI work is paused, see project notes).\n\n## Why now\n\nPhase 4 (FlatPilot-f2fa) shipped the engine; without this panel the user has to read SQLite to understand what the engine is currently doing. Closing the observability gap is a natural follow-up.","status":"closed","priority":3,"issue_type":"feature","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-08T09:51:19Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-08T20:46:36Z","closed_at":"2026-05-08T20:46:36Z","close_reason":"Added Auto-apply panel to flatpilot status (cli.py _print_auto_apply_panel) showing pause state (ACTIVE/PAUSED), per-platform usage and cooldown countdown, platforms-without-filler marker, and flats-over-max-failures count. New flats_over_max_failures helper in auto_apply.py with unit tests. 9 new tests in test_auto_apply_status_panel.py; full suite 505/505 green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-5mf","title":"Add ImmoScout24 login recipe (or formally drop)","description":"FlatPilot-8jx's UI mockup shows an 'ImmoScout24 [Connect →]' button on the Connected Accounts page, but src/flatpilot/login.py:_LOGIN_SITES has no immoscout24 entry — the underlying CLI cannot drive that flow. Today the ImmoScout24 integration is RSS-only (flatpilot.scrapers.immoscout24_rss reads URLs the user pastes from their account); there is no filler and arguably no need to seed cookies.\n\nTwo ways to resolve:\n1. Add a login recipe pointing at https://www.immobilienscout24.de/anmelden.html so the Connect button has a backend, even though no current code path consumes the cookies. Keeps the UI promise consistent.\n2. Decide RSS-only is the supported integration, drop the ImmoScout24 button from FlatPilot-8jx's scope, and close this as won't-do.\n\nResolution should pick one of those, not leave the contradiction.\n\nBlocks the ImmoScout24 part of FlatPilot-8jx.","status":"closed","priority":3,"issue_type":"task","assignee":"Mukhammad Ibrokhimov","owner":"ibrohimovmuhammad2020@gmail.com","created_at":"2026-05-05T20:35:10Z","created_by":"Mukhammad Ibrokhimov","updated_at":"2026-05-05T22:11:31Z","started_at":"2026-05-05T22:02:03Z","closed_at":"2026-05-05T22:11:31Z","close_reason":"Added login recipe for ImmoScout24 (anmelden.html) so 'flatpilot login immoscout24' and the Web UI Connect button (FlatPilot-8jx) have a backend, even though no current code path consumes the seeded cookies. Branch: feat/immoscout24-login-recipe.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -146,12 +148,12 @@ {"_type":"issue","id":"FlatPilot-u5gh","title":"O4. Docker-compose + deployment docs","description":"One-command local stack; deployment instructions for a VPS.","status":"open","priority":4,"issue_type":"task","created_at":"2026-04-20T15:52:57Z","updated_at":"2026-04-20T15:52:57Z","dependencies":[{"issue_id":"FlatPilot-u5gh","depends_on_id":"FlatPilot-x0pq","type":"blocks","created_at":"2026-04-20T17:52:57Z","created_by":"Mukhammad Ibrokhimov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"FlatPilot-x0pq","title":"O1. Architecture decision record","description":"Decide: FastAPI + Next.js; magic-link auth; SQLite -\u003e Postgres migration path; deployment story (docker-compose starter).","status":"open","priority":4,"issue_type":"task","assignee":"Mukhammad Ibrokhimov","created_at":"2026-04-20T15:52:57Z","updated_at":"2026-05-05T21:08:19Z","started_at":"2026-05-03T15:38:59Z","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"FlatPilot-z3me","title":"O2. Per-user isolation in data model","description":"All tables gain user_id; row-level scoping; migration from single-user state.","status":"open","priority":4,"issue_type":"task","created_at":"2026-04-20T15:52:57Z","updated_at":"2026-04-20T15:52:57Z","dependencies":[{"issue_id":"FlatPilot-z3me","depends_on_id":"FlatPilot-x0pq","type":"blocks","created_at":"2026-04-20T17:52:57Z","created_by":"Mukhammad Ibrokhimov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"memory","key":"commit-author-for-this-repo-mukhammad-ibrokhimov-ibrohimovmu","value":"Commit author for this repo: Mukhammad Ibrokhimov \u003cibrohimovmuhammad2020@gmail.com\u003e. In a fresh clone: git config user.email ibrohimovmuhammad2020@gmail.com \u0026\u0026 git config user.name 'Mukhammad Ibrokhimov'. Any other email on the machine is NOT for commits here."} {"_type":"memory","key":"flatpilot-no-credential-storage","value":"Never store user passwords for rental platforms (WG-Gesucht, Kleinanzeigen, ImmoScout24, future) anywhere in FlatPilot — not in profile.json, .env, or any other file. Reasons: (1) Docker container can't reach host OS keyring, leaving only file storage which is unsafe for credentials; (2) programmatic credential login is a strong anti-bot signal that risks getting accounts flagged; (3) 2FA / captchas still require human intervention even with stored passwords; (4) ToS posture is much worse than cookie reuse from a real human session. Solution: cookies-only auth. CLI version (FlatPilot-5b8) uses headed Playwright + 'press Enter when logged in' UX. Future multi-user Web UI (FlatPilot-8jx) uses browser extension or bookmarklet to capture cookies from the user's normal browser session — never asks for or stores passwords. None of WG-Gesucht / Kleinanzeigen / ImmoScout24 publish OAuth endpoints, so a literal 'Sign in with Platform' redirect-and-callback flow is structurally impossible. Decided in PR #18 session 2026-04-22."} {"_type":"memory","key":"clean-room-policy-flatpilot-is-independent-mit-licensed","value":"Clean-room policy: FlatPilot is independent MIT-licensed. Never copy code from any other project (including any job-hunting agent that may have inspired the high-level architecture). Read outside code only as a design reference; always rewrite from scratch."} +{"_type":"memory","key":"after-a-flatpilot-pr-merges-do-not-delete","value":"After a FlatPilot PR merges, DO NOT delete the local or remote feature branch, and DO NOT drop stashes, unless the user explicitly asks. Why: user's standing preference to preserve merged branches for historical reference (e.g. feat/l2-anschreiben-composer was called out as hands-off in the handoff). How to apply: after merge, only sync main (git checkout main \u0026\u0026 git pull --ff-only) and stop. Do not run 'git branch -d', 'git push --delete', 'git stash drop', or any similar cleanup without an explicit request. Treat 'cleanup' as scope the user opts into, not a default step."} {"_type":"memory","key":"flatpilot-product-direction","value":"FlatPilot is currently single-user CLI (Phase 3). Long-term direction: convert to small multi-user web app — 'Option C' scope — for friends-of-friends, no public marketing, no payments. Strict sequencing: complete Phase 3 CLI for the developer's own use FIRST, then validate by using it personally on real WG-Gesucht searches for 1-2 weeks, THEN start Epic O (Web UI / FlatPilot-0wfb). Do NOT start FastAPI / React / Postgres work until Phase 3 is shipped end-to-end and personally validated. The CLI work is not throwaway — every Python module becomes a backend module the FastAPI app calls later. Phase 5 will need: Hetzner/Fly hosting, Postgres migration, FastAPI backend, React frontend, magic-link email auth, browser extension OR bookmarklet for per-user platform cookie capture. Decided in PR #18 session 2026-04-22."} {"_type":"memory","key":"flatpilot-prompt-ride-along-checklist","value":"When the user's prompt has a 'Possible ride-alongs' section that explicitly lists candidates for inline inclusion, treat each listed item as a TASK ITEM, not just background reading. Add it to the working checklist before starting implementation, and verify each one is either (a) included in the PR or (b) explicitly justified as skipped before declaring the work done. Why: in PR #30 (FlatPilot-etu) the user explicitly suggested 'A README note about what flatpilot scrape --platform inberlinwohnen now ingests on fresh install → ride along inline' and I dropped it; only caught it when the user asked 'do we have any more related PR tasks'. How to apply: when reading any FlatPilot prompt, scan for 'ride-along' / 'inline' / 'fix inline' suggestions, list them in your initial pitch back to the user, and tick them off as you go."} -{"_type":"memory","key":"never-push-directly-to-main-every-change-goes","value":"Never push directly to main. Every change goes through a PR: git fetch origin; git checkout -b \u003ctype\u003e/\u003cslug\u003e origin/main; commit; git push -u origin \u003cbranch\u003e; gh pr create --base main --head \u003cbranch\u003e --fill. Return the PR URL and stop. Force-push allowed only on personal feature branches that nobody else has pulled (--force-with-lease)."} -{"_type":"memory","key":"after-a-flatpilot-pr-merges-do-not-delete","value":"After a FlatPilot PR merges, DO NOT delete the local or remote feature branch, and DO NOT drop stashes, unless the user explicitly asks. Why: user's standing preference to preserve merged branches for historical reference (e.g. feat/l2-anschreiben-composer was called out as hands-off in the handoff). How to apply: after merge, only sync main (git checkout main \u0026\u0026 git pull --ff-only) and stop. Do not run 'git branch -d', 'git push --delete', 'git stash drop', or any similar cleanup without an explicit request. Treat 'cleanup' as scope the user opts into, not a default step."} -{"_type":"memory","key":"commit-author-for-this-repo-mukhammad-ibrokhimov-ibrohimovmu","value":"Commit author for this repo: Mukhammad Ibrokhimov \u003cibrohimovmuhammad2020@gmail.com\u003e. In a fresh clone: git config user.email ibrohimovmuhammad2020@gmail.com \u0026\u0026 git config user.name 'Mukhammad Ibrokhimov'. Any other email on the machine is NOT for commits here."} {"_type":"memory","key":"for-flatpilot-batch-several-small-beads-tasks-into","value":"For FlatPilot: one PR per epic. If an epic has \u003e10 tasks, split into multiple PRs (thematic split, topologically ordered). Otherwise the whole epic lands in one PR — one commit per task, each commit message referencing its beads ID. Small leftover tasks from completed sub-areas can ride along if they fit the theme. Why: the user set this rule after observing that per-task PRs were noise and mixed-theme mega-PRs were hard to review. How to apply: before claiming the first task in an epic, count the epic's open tasks with 'bd list' filtered to that epic; if \u003e10, decide on a thematic split (e.g. 'matcher core' vs 'matcher CLI') and declare it in the first PR description. Each PR closes every beads task it contains."} +{"_type":"memory","key":"never-push-directly-to-main-every-change-goes","value":"Never push directly to main. Every change goes through a PR: git fetch origin; git checkout -b \u003ctype\u003e/\u003cslug\u003e origin/main; commit; git push -u origin \u003cbranch\u003e; gh pr create --base main --head \u003cbranch\u003e --fill. Return the PR URL and stop. Force-push allowed only on personal feature branches that nobody else has pulled (--force-with-lease)."} {"_type":"memory","key":"no-ai-co-author-trailers-in-commits-no","value":"No AI co-author trailers in commits. No 'Co-Authored-By: Claude ...', no '🤖 Generated with Claude Code'. Commits are authored by the human only."} diff --git a/src/flatpilot/apply.py b/src/flatpilot/apply.py index 779c879..a043153 100644 --- a/src/flatpilot/apply.py +++ b/src/flatpilot/apply.py @@ -267,6 +267,7 @@ def apply_to_flat( attachments=attachments, submit=False, screenshot_dir=screenshot_dir, + profile=profile, ) return ApplyOutcome( status="dry_run", @@ -294,6 +295,7 @@ def apply_to_flat( attachments=attachments, submit=True, screenshot_dir=screenshot_dir, + profile=profile, ) except FillError as exc: # ListingExpiredError is recorded with the ``auto_skipped:`` diff --git a/src/flatpilot/fillers/base.py b/src/flatpilot/fillers/base.py index 0889f25..3aefcfd 100644 --- a/src/flatpilot/fillers/base.py +++ b/src/flatpilot/fillers/base.py @@ -19,10 +19,13 @@ from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar, Protocol +from typing import TYPE_CHECKING, ClassVar, Protocol from flatpilot.errors import FlatPilotError +if TYPE_CHECKING: + from flatpilot.profile import Profile + class FillError(FlatPilotError): """Base class for all filler errors.""" @@ -122,6 +125,7 @@ def fill( *, submit: bool, screenshot_dir: Path | None = None, + profile: Profile | None = None, ) -> FillReport: """Navigate to ``listing_url``, open the contact form, fill it. @@ -131,6 +135,11 @@ def fill( stop at the filled-but-unsent form and return — useful for previews. + ``profile`` (optional) lets a filler read additional structured + fields off ``profile.contact_details`` for platforms whose + contact form requests them (Kleinanzeigen, FlatPilot-ic1). + Fillers that don't use it simply ignore the argument. + Implementations MUST NOT attempt to log in. Failures should raise the most specific error class available — :class:`NotAuthenticatedError` when the page redirects to login, diff --git a/src/flatpilot/fillers/kleinanzeigen.py b/src/flatpilot/fillers/kleinanzeigen.py index ab80274..bce4dd9 100644 --- a/src/flatpilot/fillers/kleinanzeigen.py +++ b/src/flatpilot/fillers/kleinanzeigen.py @@ -59,6 +59,7 @@ SelectorMissingError, SubmitVerificationError, ) +from flatpilot.profile import Profile from flatpilot.scrapers.kleinanzeigen import CONSENT_SELECTORS, HOST, WARMUP_URL from flatpilot.scrapers.session import ( DEFAULT_USER_AGENT, @@ -147,7 +148,15 @@ def fill( *, submit: bool, screenshot_dir: Path | None = None, + profile: Profile | None = None, ) -> FillReport: + # ``profile`` is accepted for forward compatibility with + # FlatPilot-ic1 Phase 2 (structured-field filling). Phase 1 + # threads the argument through every layer so Phase 2 can land + # without further API churn. Until Phase 2, the value is unused + # here — Kleinanzeigen still relies on account-prefill for + # whatever structured fields the form exposes. + del profile if not message.strip(): raise ValueError("message must be non-empty") for path in attachments: diff --git a/src/flatpilot/fillers/wg_gesucht.py b/src/flatpilot/fillers/wg_gesucht.py index ad96406..6227037 100644 --- a/src/flatpilot/fillers/wg_gesucht.py +++ b/src/flatpilot/fillers/wg_gesucht.py @@ -33,7 +33,10 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from flatpilot.profile import Profile from playwright.sync_api import TimeoutError as PlaywrightTimeoutError @@ -150,7 +153,12 @@ def fill( *, submit: bool, screenshot_dir: Path | None = None, + profile: Profile | None = None, ) -> FillReport: + # WG-Gesucht uses no structured contact-form fields, so ``profile`` + # is accepted for protocol compatibility (FlatPilot-ic1) and + # ignored here. + del profile if not message.strip(): raise ValueError("message must be non-empty") for path in attachments: diff --git a/src/flatpilot/profile.example.json b/src/flatpilot/profile.example.json index 15f1a57..869be4f 100644 --- a/src/flatpilot/profile.example.json +++ b/src/flatpilot/profile.example.json @@ -22,6 +22,16 @@ "wbs": { "status": "none" }, + "contact_details": { + "anrede": null, + "given_name": null, + "surname": null, + "phone": null, + "street": null, + "plz": null, + "schufa_status": null, + "household_type": null + }, "notifications": { "telegram": { "enabled": false, diff --git a/src/flatpilot/profile.py b/src/flatpilot/profile.py index 263834f..c3257c7 100644 --- a/src/flatpilot/profile.py +++ b/src/flatpilot/profile.py @@ -23,6 +23,29 @@ IncomeCategory = Literal[100, 140, 160, 180] EmploymentStatus = Literal["student", "employed", "self_employed", "other"] FurnishedPref = Literal["any", "furnished", "unfurnished"] +Anrede = Literal["frau", "herr", "divers", "keine_angabe"] +SchufaStatus = Literal["available", "no", "on_request"] +HouseholdType = Literal["single", "couple", "family", "wg", "other"] + + +class ContactDetails(BaseModel): + """Optional contact-form fields for platforms that ask for them. + + Today only Kleinanzeigen surfaces these — and only for some landlords + (FlatPilot-ic1). Each field is independently optional; a filler that + can fill the field will, otherwise it leaves the form's existing + value (typically empty) alone. The matcher ignores this block. + """ + model_config = ConfigDict(extra="forbid") + + anrede: Anrede | None = None + given_name: str | None = None + surname: str | None = None + phone: str | None = None + street: str | None = None + plz: str | None = None + schufa_status: SchufaStatus | None = None + household_type: HouseholdType | None = None class WBS(BaseModel): @@ -206,6 +229,7 @@ class Profile(BaseModel): exclude_short_term: bool = True wbs: WBS = Field(default_factory=WBS) + contact_details: ContactDetails = Field(default_factory=ContactDetails) notifications: Notifications = Field(default_factory=Notifications) attachments: Attachments = Field(default_factory=Attachments) auto_apply: AutoApplySettings = Field(default_factory=AutoApplySettings) diff --git a/src/flatpilot/wizard/init.py b/src/flatpilot/wizard/init.py index 03ac0ad..26922c0 100644 --- a/src/flatpilot/wizard/init.py +++ b/src/flatpilot/wizard/init.py @@ -34,6 +34,7 @@ from flatpilot.matcher.distance import geocode from flatpilot.profile import ( WBS, + ContactDetails, EmailNotification, EmailNotificationOverride, Notifications, @@ -493,6 +494,15 @@ def run(console: Console | None = None) -> Path | None: out.rule("WBS (Wohnberechtigungsschein)") wbs = _collect_wbs(out, defaults.wbs) + out.rule("Kleinanzeigen contact details (optional)") + out.print( + "[dim]These are Kleinanzeigen-specific contact-form fields some " + "landlords request. Each prompt accepts blank to skip; FlatPilot will " + "only fill fields the form actually exposes and the profile defines. " + "Other platforms ignore these.[/dim]" + ) + contact_details = _collect_contact_details(out, defaults.contact_details) + out.rule("Notifications") notifications = _collect_notifications(out, defaults.notifications) @@ -518,6 +528,7 @@ def run(console: Console | None = None) -> Path | None: "min_contract_months": min_contract_months, "exclude_short_term": exclude_short_term, "wbs": wbs, + "contact_details": contact_details, "notifications": notifications, } try: @@ -751,6 +762,79 @@ def _collect_wbs(out: Console, current: WBS) -> WBS: ) +def _collect_contact_details(out: Console, current: ContactDetails) -> ContactDetails: + if not Confirm.ask( + "Add / edit Kleinanzeigen contact-form details now?", + default=any( + getattr(current, f) is not None + for f in ( + "anrede", "given_name", "surname", "phone", + "street", "plz", "schufa_status", "household_type", + ) + ), + ): + return current + + anrede = Prompt.ask( + "Anrede (blank = skip)", + choices=["frau", "herr", "divers", "keine_angabe", ""], + default=current.anrede or "", + show_choices=True, + show_default=False, + ) or None + + given_name = _prompt_optional_str( + out, "Vorname (given name; blank = skip)", + default=current.given_name or "", + ) + surname = _prompt_optional_str( + out, "Nachname (surname; blank = skip)", + default=current.surname or "", + ) + phone = _prompt_optional_str( + out, "Telefon (blank = skip)", + default=current.phone or "", + ) + street = _prompt_optional_str( + out, "Straße / Nr. (blank = skip)", + default=current.street or "", + ) + plz = _prompt_optional_str( + out, "PLZ (5-digit postal code; blank = skip)", + default=current.plz or "", + ) + + schufa_status = Prompt.ask( + "SCHUFA-Auskunft (blank = skip)", + choices=["available", "no", "on_request", ""], + default=current.schufa_status or "", + show_default=False, + ) or None + + household_type = Prompt.ask( + "Haushaltstyp (blank = skip)", + choices=["single", "couple", "family", "wg", "other", ""], + default=current.household_type or "", + show_default=False, + ) or None + + return ContactDetails( + anrede=anrede, # type: ignore[arg-type] + given_name=given_name, + surname=surname, + phone=phone, + street=street, + plz=plz, + schufa_status=schufa_status, # type: ignore[arg-type] + household_type=household_type, # type: ignore[arg-type] + ) + + +def _prompt_optional_str(out: Console, prompt: str, *, default: str) -> str | None: + raw = Prompt.ask(prompt, default=default) + return raw.strip() or None + + def _collect_notifications(out: Console, current: Notifications) -> Notifications: tg_enabled = Confirm.ask("Enable Telegram?", default=current.telegram.enabled) if tg_enabled: diff --git a/tests/test_apply_lock.py b/tests/test_apply_lock.py index c72a594..b311512 100644 --- a/tests/test_apply_lock.py +++ b/tests/test_apply_lock.py @@ -245,7 +245,10 @@ def test_apply_to_flat_acquires_and_releases_lock_on_success( _write_template(tmp_path) flat_id = _insert_flat(tmp_db) - def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None): + def fake_fill( + self, listing_url, message, attachments, *, + submit, screenshot_dir=None, profile=None, + ): # While the filler is running, the lock row must exist. row = tmp_db.execute( "SELECT pid FROM apply_locks WHERE flat_id = ?", (flat_id,) @@ -313,7 +316,10 @@ def test_apply_to_flat_dry_run_does_not_touch_lock(tmp_db, tmp_path, monkeypatch _write_template(tmp_path) flat_id = _insert_flat(tmp_db) - def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None): + def fake_fill( + self, listing_url, message, attachments, *, + submit, screenshot_dir=None, profile=None, + ): return FillReport( platform="wg-gesucht", listing_url=listing_url, diff --git a/tests/test_apply_orchestrator.py b/tests/test_apply_orchestrator.py index f0ca8ae..0461303 100644 --- a/tests/test_apply_orchestrator.py +++ b/tests/test_apply_orchestrator.py @@ -72,7 +72,10 @@ def _write_template(tmp_path: Path) -> None: def _stub_filler(monkeypatch, *, submitted: bool = True, raises: Exception | None = None): captured: dict = {} - def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None): + def fake_fill( + self, listing_url, message, attachments, *, + submit, screenshot_dir=None, profile=None, + ): captured.update( { "listing_url": listing_url, @@ -80,6 +83,7 @@ def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir "attachments": attachments, "submit": submit, "screenshot_dir": screenshot_dir, + "profile": profile, } ) if raises is not None: @@ -121,6 +125,11 @@ def test_apply_dry_run_writes_no_row(tmp_db, tmp_path, monkeypatch): assert captured["submit"] is False assert "interessiert an Bright 2-room Friedrichshain" in captured["message"] assert tmp_db.execute("SELECT COUNT(*) FROM applications").fetchone()[0] == 0 + # FlatPilot-ic1 Phase 1: apply orchestrator must thread the loaded + # Profile through to the filler so a future structured-field filler + # can read profile.contact_details without further plumbing. + from flatpilot.profile import Profile as _Profile + assert isinstance(captured["profile"], _Profile) def test_apply_live_writes_submitted_row(tmp_db, tmp_path, monkeypatch): diff --git a/tests/test_contact_details_schema.py b/tests/test_contact_details_schema.py new file mode 100644 index 0000000..0d46942 --- /dev/null +++ b/tests/test_contact_details_schema.py @@ -0,0 +1,53 @@ +"""Tests for the ContactDetails sub-model (FlatPilot-ic1 Phase 1).""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from flatpilot.profile import ContactDetails, Profile + + +def test_contact_details_defaults_are_all_none(): + cd = ContactDetails() + for field in ( + "anrede", "given_name", "surname", "phone", + "street", "plz", "schufa_status", "household_type", + ): + assert getattr(cd, field) is None + + +def test_contact_details_accepts_full_payload(): + cd = ContactDetails( + anrede="herr", + given_name="Max", + surname="Müller", + phone="+49 30 12345678", + street="Musterstraße 1", + plz="10115", + schufa_status="available", + household_type="couple", + ) + assert cd.anrede == "herr" + assert cd.given_name == "Max" + assert cd.schufa_status == "available" + + +def test_contact_details_rejects_unknown_field(): + with pytest.raises(ValidationError): + ContactDetails(anrede="herr", unknown_field=42) + + +def test_contact_details_rejects_invalid_anrede(): + with pytest.raises(ValidationError): + ContactDetails(anrede="mister") # type: ignore[arg-type] + + +def test_contact_details_rejects_invalid_schufa_status(): + with pytest.raises(ValidationError): + ContactDetails(schufa_status="green") # type: ignore[arg-type] + + +def test_profile_default_contact_details_block_is_present_and_empty(): + profile = Profile.load_example() + assert profile.contact_details.anrede is None + assert profile.contact_details.surname is None diff --git a/tests/test_wizard_contact_details.py b/tests/test_wizard_contact_details.py new file mode 100644 index 0000000..0500713 --- /dev/null +++ b/tests/test_wizard_contact_details.py @@ -0,0 +1,73 @@ +"""Tests for the wizard's _collect_contact_details prompt loop (bd-ic1).""" +from __future__ import annotations + +from io import StringIO + +from rich.console import Console + +from flatpilot.profile import ContactDetails +from flatpilot.wizard.init import _collect_contact_details + + +def _capture_console(): + return Console(file=StringIO(), force_terminal=False, width=120) + + +def test_collect_contact_details_skips_when_user_declines(monkeypatch): + """Confirm=False at the gate prompt returns the existing details unchanged.""" + out = _capture_console() + current = ContactDetails() + monkeypatch.setattr( + "flatpilot.wizard.init.Confirm.ask", lambda *a, **kw: False + ) + result = _collect_contact_details(out, current) + assert result == current + assert result.anrede is None + + +def test_collect_contact_details_blank_answers_yield_none_per_field(monkeypatch): + """All-blank answers must produce a ContactDetails with every field None.""" + out = _capture_console() + monkeypatch.setattr( + "flatpilot.wizard.init.Confirm.ask", lambda *a, **kw: True + ) + monkeypatch.setattr( + "flatpilot.wizard.init.Prompt.ask", lambda *a, **kw: "" + ) + result = _collect_contact_details(out, ContactDetails()) + for field in ( + "anrede", "given_name", "surname", "phone", + "street", "plz", "schufa_status", "household_type", + ): + assert getattr(result, field) is None + + +def test_collect_contact_details_persists_provided_values(monkeypatch): + """A full answer set round-trips into a populated ContactDetails.""" + out = _capture_console() + monkeypatch.setattr( + "flatpilot.wizard.init.Confirm.ask", lambda *a, **kw: True + ) + answers = iter([ + "herr", # anrede + "Max", # given_name + "Müller", # surname + "+49 30 1234", # phone + "Musterstraße 1", + "10115", # plz + "available", # schufa_status + "couple", # household_type + ]) + monkeypatch.setattr( + "flatpilot.wizard.init.Prompt.ask", + lambda *a, **kw: next(answers), + ) + result = _collect_contact_details(out, ContactDetails()) + assert result.anrede == "herr" + assert result.given_name == "Max" + assert result.surname == "Müller" + assert result.phone == "+49 30 1234" + assert result.street == "Musterstraße 1" + assert result.plz == "10115" + assert result.schufa_status == "available" + assert result.household_type == "couple"