SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
11.4 KB · 191 lines python
Raw Blame History
1"""Integration: the pipeline against the local Postgres with a temporary `ztest-` company (skipped when the DB is unavailable)."""2from __future__ import annotations34import asyncio5from typing import Any67import pytest8from conftest import FakeFetcher, fixture_path910from companyatlas.fetch import FetchError, NotModified, file_result11from companyatlas.taxonomy import FailureClass1213BASE = "https://www.acme-cloud.example"141516async def _setup() -> tuple[str, dict[str, str]]:17    from companyatlas.db import execute, jsonb, transaction18    from companyatlas.ids import new_id1920    cid = new_id("company")21    slug = f"ztest-pipe-{cid[-6:].lower()}"22    dom = f"{slug}.example"23    sensors: dict[str, str] = {}24    async with transaction() as conn:25        await execute(conn, """insert into companies (id, slug, display_name, canonical_domain, website, tier, importance, onboarding_status)26                               values (:id, :slug, 'Pipeline test', :dom, :web, 3, 0.4, 'active')""", id=cid, slug=slug, dom=dom, web=f"https://{dom}/")27        for surface, url, con in [("pricing", BASE + "/pricing", "generic-html-v1"), ("careers", BASE + "/careers", "generic-html-v1"),28                                  ("jobs_board", "https://boards-api.greenhouse.io/v1/boards/ztest/jobs?content=false", "greenhouse-v1"),29                                  ("leadership", BASE + "/about/leadership", "generic-html-v1"), ("locations", BASE + "/company/locations", "generic-html-v1"),30                                  ("newsroom", BASE + "/news", "generic-html-v1"), ("legal_terms", BASE + "/legal/terms", "generic-html-v1")]:31            sid = new_id("sensor")32            await execute(conn, """insert into sensors (id, company_id, surface, connector_id, url, canonical_url, domain, base_interval_s, current_interval_s, status, config)33                                   values (:id, :c, :s, :con, :u, :u, :d, 43200, 43200, 'pending', cast(:cfg as jsonb))""",34                          id=sid, c=cid, s=surface, con=con, u=url, d=dom, cfg=jsonb({"canonical_domain": "acme-cloud.example", "token": "ztest"}))35            sensors[surface] = sid36    return cid, sensors373839async def _teardown(cid: str) -> None:40    from companyatlas.db import dispose, execute, transaction4142    async with transaction() as conn:43        await execute(conn, "delete from companies where id = :c", c=cid)44        await execute(conn, "delete from domain_budgets where domain like 'ztest-%'")45    await dispose()464748async def _sensor(sid: str) -> dict[str, Any]:49    from companyatlas.db import fetch_one, transaction5051    async with transaction() as conn:52        row = await fetch_one(conn, "select * from sensors where id = :id", id=sid)53    assert row is not None54    return row555657async def _run(sid: str, path: str, ct: str = "text/html", fetcher: Any = None):  # type: ignore[no-untyped-def]58    from companyatlas.services.pipeline import run_sensor5960    row = await _sensor(sid)61    return await run_sensor(row, fetcher=fetcher or FakeFetcher(), worker="pytest", result=file_result(path, url=row["url"], content_type=ct))626364async def _q(sql: str, **params: Any) -> list[dict[str, Any]]:65    from companyatlas.db import fetch_all, transaction6667    async with transaction() as conn:68        return await fetch_all(conn, sql, **params)697071@pytest.mark.usefixtures("db")72def test_pipeline_end_to_end() -> None:73    async def scenario() -> None:74        cid, s = await _setup()75        try:76            gh = fixture_path("greenhouse", "stripe_jobs.json")77            # ---- baseline runs (version 1, no change rows, entities inserted)78            o = await _run(s["pricing"], fixture_path("generic_html", "pricing.html"))79            assert o.status == "ok" and o.snapshot_id and not o.change_id and o.delta_counts == {"plans_added": 3}80            # ---- same content again → unchanged, no snapshot, interval grows ×1.2581            o = await _run(s["pricing"], fixture_path("generic_html", "pricing.html"))82            assert o.status == "unchanged" and o.snapshot_id is None and o.interval_s == int(43200 * 1.25)83            # ---- price change → new snapshot version, change row pending, plan superseded, burst interval84            o = await _run(s["pricing"], fixture_path("generic_html", "pricing_v2.html"))85            assert o.status == "changed" and o.kind in ("major", "critical") and o.change_id and o.delta_counts.get("plans_price_changed") == 186            assert o.interval_s == 90087            plans = await _q("select plan_name, price, status, version_no from pricing_plans where company_id = :c order by plan_name, version_no", c=cid)88            starter = [p for p in plans if p["plan_name"] == "Starter"]89            assert [(int(p["price"]), p["status"], p["version_no"]) for p in starter] == [(29, "superseded", 1), (39, "current", 2)]90            chg = (await _q("select * from changes where company_id = :c and surface = 'pricing'", c=cid))[0]91            assert chg["status"] == "pending" and chg["structured_delta"]["plans"]["price_changed"][0]["after"] == 39 and chg["diff"]["counts"]["modified"] == 192            snaps = await _q("select version_no, previous_snapshot_id from snapshots where sensor_id = :s order by version_no", s=s["pricing"])93            assert [x["version_no"] for x in snaps] == [1, 2] and snaps[1]["previous_snapshot_id"] == (await _q("select id from snapshots where sensor_id = :s and version_no = 1", s=s["pricing"]))[0]["id"]94            # ---- careers: jobs added / removed with no_longer_listed95            await _run(s["careers"], fixture_path("generic_html", "careers.html"))96            o = await _run(s["careers"], fixture_path("generic_html", "careers_v2.html"))97            assert o.delta_counts == {"jobs_added": 2, "jobs_removed": 1}98            jobs = await _q("select title, status, removed_at, is_ai from jobs where sensor_id = :s order by title", s=s["careers"])99            by = {j["title"]: j for j in jobs}100            assert by["People Operations Lead"]["status"] == "no_longer_listed" and by["People Operations Lead"]["removed_at"] is not None101            assert by["AI Research Scientist"]["is_ai"] and by["AI Research Scientist"]["status"] == "open"102            assert len(jobs) == 7103            # ---- untrusted extraction never mass-removes: a page with no job listing leaves the 6 open jobs alone104            o = await _run(s["careers"], fixture_path("generic_html", "legal_terms.html"))105            open_after = await _q("select count(*) n from jobs where sensor_id = :s and status = 'open'", s=s["careers"])106            assert open_after[0]["n"] == 6107            # ---- structured board: baseline + unchanged108            o = await _run(s["jobs_board"], gh, "application/json")109            assert o.status == "ok" and o.delta_counts == {"jobs_added": 20}110            o = await _run(s["jobs_board"], gh, "application/json")111            assert o.status == "unchanged"112            # ---- other surfaces baseline113            for surf, fn in (("leadership", "leadership.html"), ("locations", "locations.html"), ("newsroom", "newsroom.html"), ("legal_terms", "legal_terms.html")):114                o = await _run(s[surf], fixture_path("generic_html", fn))115                assert o.status == "ok", (surf, o.error)116            assert (await _q("select count(*) n from people where company_id = :c and is_executive", c=cid))[0]["n"] == 4117            assert (await _q("select count(*) n from locations where company_id = :c and country = 'US'", c=cid))[0]["n"] == 2118            assert (await _q("select count(*) n from news_items where company_id = :c", c=cid))[0]["n"] == 4119            # ---- failure path: fetch error → observation + failures row + backoff; repeated → failing120            from companyatlas.services.pipeline import run_sensor121122            ff = FakeFetcher()123            ff.add_error(BASE + "/legal/terms", FetchError("http 404", status=404, url=BASE + "/legal/terms", failure=FailureClass.PAGE_REMOVED))124            row = await _sensor(s["legal_terms"])125            o = await run_sensor(row, fetcher=ff, worker="pytest")  # type: ignore[arg-type]126            assert o.status == "failed" and o.failure_class == "PAGE_REMOVED" and o.interval_s == min(7 * 86400, int(row["current_interval_s"] * 4.0))127            o = await run_sensor(await _sensor(s["legal_terms"]), fetcher=ff, worker="pytest")  # type: ignore[arg-type]128            assert o.sensor_status == "failing"129            fails = await _q("select failure_class from failures where sensor_id = :s", s=s["legal_terms"])130            assert len(fails) == 2131            # ---- not modified path132            ff2 = FakeFetcher()133            ff2.add_error(BASE + "/news", NotModified(5))134            o = await run_sensor(await _sensor(s["newsroom"]), fetcher=ff2, worker="pytest")  # type: ignore[arg-type]135            assert o.status == "not_modified"136            obs = await _q("select not_modified from observations where sensor_id = :s order by fetched_at desc limit 1", s=s["newsroom"])137            assert obs[0]["not_modified"] is True138            # ---- robots block → review queue item139            ff3 = FakeFetcher()140            from companyatlas.fetch import BlockedError141142            ff3.add_error(BASE + "/company/locations", BlockedError("robots", url=BASE + "/company/locations", failure=FailureClass.ROBOTS))143            o = await run_sensor(await _sensor(s["locations"]), fetcher=ff3, worker="pytest")  # type: ignore[arg-type]144            assert o.sensor_status == "blocked"145            assert (await _q("select count(*) n from review_queue where ref_id = :s and kind = 'blocked_source'", s=s["locations"]))[0]["n"] == 1146            # ---- company + ledger touched147            comp = (await _q("select stats, first_observed_at, last_change_at from companies where id = :c", c=cid))[0]148            assert comp["first_observed_at"] is not None and comp["last_change_at"] is not None and comp["stats"]["meaningful_changes"] >= 2149            led = await _q("select units from cost_ledger where day = current_date and dimension = 'fetch' and key = :c", c=cid)150            assert led and led[0]["units"] >= 10151        finally:152            await _teardown(cid)153154    asyncio.run(scenario())155156157@pytest.mark.usefixtures("db")158def test_scheduler_claims_and_releases() -> None:159    async def scenario() -> None:160        from companyatlas.services.scheduler import claim_due_sensors, release_claims161162        cid, _sensors = await _setup()163        try:164            rows = await claim_due_sensors("pytest-worker", 100)165            mine = [r for r in rows if r["company_id"] == cid]166            assert len(mine) == 7 and all(r["claimed_by"] == "pytest-worker" for r in mine)167            again = await claim_due_sensors("pytest-worker-2", 100)168            assert not [r for r in again if r["company_id"] == cid]          # already claimed169            await release_claims("pytest-worker")170            assert (await _q("select count(*) n from sensors where company_id = :c and claimed_by is null", c=cid))[0]["n"] == 7171        finally:172            await _teardown(cid)173174    asyncio.run(scenario())175176177@pytest.mark.usefixtures("db")178def test_connectors_table_sync() -> None:179    async def scenario() -> None:180        from companyatlas.db import dispose, transaction181        from companyatlas.sdk.connector import all_connectors, sync_connectors_table182183        async with transaction() as conn:184            n = await sync_connectors_table(conn)185        assert n == len(all_connectors()) >= 14186        rows = await _q("select id from connectors where id = 'generic-html-v1'")187        assert rows188        await dispose()189190    asyncio.run(scenario())191