"""Helpers for the feed and drawing tests: the generator, fixture dirs, feed settings and one job run. `run_feed(settings, state)` and `run_drawings(settings)` run the real job functions (visual-feed, drawing-lookup) once, the way the worker would, over a connection as the worker role, against the fixture files in `settings.fixture_dir`. Never VISUAL or M-Files. """ from __future__ import annotations import importlib import sys from datetime import date, timedelta from pathlib import Path from typing import Any from pshelpers import make_settings from psycopg.types.json import Jsonb from packetstation.settings import Settings ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) gen = importlib.import_module("tests.fixtures.generator") FEED_JOB = "visual-feed" DRAWING_JOB = "drawing-lookup" FEED_COUNT_KEYS = ( "wos_window", "wos_tracked", "rows", "materials", "orders", "labor_legs", "new_drafts", "drafts_updated", "cos_opened", "cos_reaimed", "cos_withdrawn", "retired", "wo_gone_flagged", "closed_observed", "ignored_golive", "projects_created", "units_created", "auto_released", "unmapped_resources", "skipped_removed", "skipped_closed", "suspect", ) # The counts that are zero when VISUAL did not change between two runs. CHANGE_KEYS = ( "new_drafts", "drafts_updated", "cos_opened", "cos_reaimed", "cos_withdrawn", "retired", "wo_gone_flagged", "closed_observed", "projects_created", "units_created", "auto_released", ) def fixture_dir(tmp: Path, today: date | None = None) -> Path: """Generate the fixtures into tmp/fixtures (state 1).""" out: Path = gen.generate(tmp / "fixtures", today) return out def switch_state(directory: Path, n: int | str) -> None: (Path(directory) / "state").write_text(str(n), encoding="utf-8") def feed_settings_over(base: Settings, tmp: Path) -> Settings: """`base` (a pg_settings) with the fixture sources pointed at freshly generated files under tmp.""" d = fixture_dir(tmp) return base.model_copy(update={"feed_source": "fixture", "drawing_source": "fixture", "fixture_dir": d}) def fixture_settings(tmp: Path, **over: Any) -> Settings: """Plain test settings (no database) with fixture sources.""" return make_settings(tmp, feed_source="fixture", drawing_source="fixture", fixture_dir=tmp / "fixtures", **over) def set_feed(settings: Settings, **values: Any) -> None: """Merge values into the `feed` settings row by SQL (no audit: test setup).""" from pshelpers import app_conn from packetstation.feed import config with app_conn(settings) as conn: cur = config.load(conn).model_dump(mode="json") cur.update({k: (v.isoformat() if isinstance(v, date) else v) for k, v in values.items()}) conn.execute( "INSERT INTO settings (key, value, version, updated_at) VALUES ('feed', %s, 1, now())" " ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1", (Jsonb(cur),), ) def golive_today_offset() -> date: return date.today() + timedelta(days=gen.GOLIVE_OFFSET) # ---------------------------------------------------------------- one job run def scheduler(settings: Settings) -> Any: """An unstarted worker Scheduler: what runs.run_job needs, without APScheduler or the listener.""" from packetstation.worker.scheduler import Scheduler return Scheduler(settings, build="pytest") def run_job(settings: Settings, job: str, *, requested: bool = False, allow_fail: bool = False) -> dict[str, Any]: """Run one registered job once through runs.run_job, as the worker does. Returns the job_runs row. `requested` inserts a job_requests row first, so the run is a "run now". A failed run raises AssertionError with its errors unless allow_fail. """ from packetstation import db from packetstation.worker import runs if requested: with db.connect_as("worker", settings, autocommit=True) as conn: conn.execute("INSERT INTO job_requests (job, note) VALUES (%s, 'pytest')", (job,)) row = runs.run_job(scheduler(settings), job, "schedule") if not allow_fail: assert row["status"] != "failed", row.get("errors") return dict(row) def _counts(row: dict[str, Any]) -> dict[str, Any]: counts = dict(row.get("counts") or {}) counts.update( {"_status": row["status"], "_errors": row.get("errors") or [], "_run_id": row["id"], "_trigger": row["trigger"]} ) return counts def run_feed( settings: Settings, state: int | None = None, *, requested: bool = False, allow_fail: bool = False ) -> dict[str, Any]: """Point the fixtures at `state` (when given) and run the visual-feed job once. Returns the run's counts with `_status`, `_errors`, `_run_id` and `_trigger` added.""" if state is not None: switch_state(settings.fixture_dir, state) return _counts(run_job(settings, FEED_JOB, requested=requested, allow_fail=allow_fail)) def run_drawings(settings: Settings, state: int | None = None, *, requested: bool = False) -> dict[str, Any]: if state is not None: switch_state(settings.fixture_dir, state) return _counts(run_job(settings, DRAWING_JOB, requested=requested)) # ---------------------------------------------------------------- reading back def packet_by_key(conn: Any, key: str, *, live: bool = True) -> dict[str, Any] | None: from psycopg.rows import dict_row with conn.cursor(row_factory=dict_row) as cur: live_sql = "SELECT * FROM packets WHERE visual_key = %s AND removed_at IS NULL ORDER BY id DESC LIMIT 1" any_sql = "SELECT * FROM packets WHERE visual_key = %s ORDER BY id DESC LIMIT 1" row = cur.execute(live_sql if live else any_sql, (key,)).fetchone() return dict(row) if row else None def packet_id(conn: Any, key: str) -> int: p = packet_by_key(conn, key) assert p is not None, key return int(p["id"]) def legs_of(conn: Any, pid: int) -> list[tuple[Any, ...]]: return [ tuple(r) for r in conn.execute( "SELECT wo_base, lot, split, sub, seq, qty, target_hrs, visual_status, missing_runs, gone_at IS NOT NULL" " FROM packet_work_orders WHERE packet_id = %s ORDER BY wo_base, lot, split, sub, seq", (pid,), ).fetchall() ] def cos_of(conn: Any, pid: int) -> list[dict[str, Any]]: from psycopg.rows import dict_row with conn.cursor(row_factory=dict_row) as cur: return [ dict(r) for r in cur.execute("SELECT * FROM change_orders WHERE packet_id = %s ORDER BY co_no", (pid,)).fetchall() ] def move_status(conn: Any, pid: int, status: str) -> None: """Move a packet past draft by SQL (test setup: the transitions are tested elsewhere).""" conn.execute( "UPDATE packets SET status = %s::packet_status, status_at = now(), heat_map = coalesce(heat_map, true)," " version = version + 1 WHERE id = %s", (status, pid), ) def system_audit(conn: Any, action: str) -> list[tuple[Any, ...]]: return [ tuple(r) for r in conn.execute( "SELECT role_snapshot, user_id, subject_type, subject_id, packet_id FROM audit_events WHERE action = %s" " ORDER BY id", (action,), ).fetchall() ] __all__ = [ "CHANGE_KEYS", "FEED_COUNT_KEYS", "cos_of", "feed_settings_over", "fixture_dir", "fixture_settings", "gen", "golive_today_offset", "legs_of", "move_status", "packet_by_key", "packet_id", "run_drawings", "run_feed", "run_job", "set_feed", "switch_state", "system_audit", ]