SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
3 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
56.6 KB · 787 lines typescript
Raw Blame History
1/**2 * Integration tests against the local Postgres (repo-root .env). A small fixture (countries ZZ/ZY, operator, three3 * facilities, one project, events, a connector run with provenance / claim / event, sources with licences, one quality4 * flag) is inserted before and removed after; every id is prefixed `*_apitest`.5 */6import { afterAll, beforeAll, describe, expect, it } from "vitest";7import type { FastifyInstance } from "fastify";8import { loadEnvFile, resetEnv } from "./env.js";910loadEnvFile();11process.env.DCI_ADMIN_TOKEN = process.env.DCI_ADMIN_TOKEN && process.env.DCI_ADMIN_TOKEN !== "change-me" ? process.env.DCI_ADMIN_TOKEN : "test-admin-token";12process.env.DCI_API_CACHE = "0"; // deterministic responses while fixtures change13process.env.DCI_API_CH_LOG = "0";14process.env.DCI_WORKER_URL = "http://127.0.0.1:1"; // nothing listens: proxy routes must answer 50215resetEnv();1617const TOKEN = process.env.DCI_ADMIN_TOKEN!;18const ADMIN = { "x-dci-admin-token": TOKEN };19const F1 = "fac_apitest000001";20const F2 = "fac_apitest000002";21const F3 = "fac_apitest000003";22const OP = "op_apitest00000001";23const PRJ = "prj_apitest0000001";24const EVT = "evt_apitest0000001";25const EVT_RUN = "evt_apitest0000002";26const MET = "met_apitest0000001";27const RUN = "run_apitest0000001";28const CLAIM = "clm_apitest0000001";29const PROV_OLD = "prv_apitest0000001";30const PROV_RUN = "prv_apitest0000002";31const PROV_F3 = "prv_apitest0000003";32const FLAG = "flg_apitest0000001";33const SRC = "src_apitest";34const SRC_R = "src_apitest_restricted";3536let app: FastifyInstance;37let sql: Awaited<ReturnType<typeof import("./lib/sql.js").pg>>;3839async function cleanup(): Promise<void> {40  await sql`delete from watchlists where entity_id in (${F1}, ${F2}, ${OP}, ${PRJ})`;41  await sql`delete from quality_flags where dedupe_key like 'apitest%' or entity_id in (${F1}, ${F2}, ${F3}, ${PRJ})`;42  await sql`delete from claims where subject_id in (${F1}, ${F2}, ${F3}, ${PRJ}) or run_id = ${RUN}`;43  await sql`delete from events where id in (${EVT}, ${EVT_RUN}) or entity_id in (${F1}, ${F2}, ${F3}, ${PRJ}) or project_id = ${PRJ} or run_id = ${RUN}`;44  await sql`delete from provenance where entity_id in (${F1}, ${F2}, ${F3}, ${OP}, ${PRJ}) or run_id = ${RUN}`;45  await sql`delete from project_timeline where project_id = ${PRJ}`;46  await sql`delete from facility_aliases where facility_id in (${F1}, ${F2}, ${F3})`;47  await sql`delete from entity_keys where entity_id in (${F1}, ${F2}, ${F3}, ${PRJ})`;48  await sql`delete from connector_runs where id = ${RUN}`;49  await sql`delete from projects where id = ${PRJ}`;50  await sql`update facilities set parent_facility_id = null where parent_facility_id in (${F1}, ${F2}, ${F3})`;51  await sql`delete from facilities where id in (${F1}, ${F2}, ${F3})`;52  await sql`delete from metros where id = ${MET}`;53  await sql`delete from operators where id = ${OP}`;54  await sql`delete from countries where iso2 in ('ZZ', 'ZY')`;55  await sql`delete from sources where id in (${SRC}, ${SRC_R})`;56}5758beforeAll(async () => {59  const { pg } = await import("./lib/sql.js");60  sql = pg();61  await cleanup();62  await sql`insert into sources (id, connector_id, name, domain, kind, priority, url, license, attribution, redistribution) values63    (${SRC}, 'apitest', 'Apitest Source', 'example.test', 'operator', 2, 'https://example.test', 'CC-BY-4.0', 'Apitest Source', 'attribution'),64    (${SRC_R}, 'apitest-restricted', 'Apitest Restricted Source', 'restricted.test', 'dataset', 3, 'https://restricted.test', 'proprietary', 'Restricted', 'restricted')`;65  await sql`insert into countries (iso2, iso3, slug, name, region, subregion, lat, lng, renewable_share, electricity_twh, stats_year) values ('ZZ', 'ZZZ', 'apitest-land', 'Apitest Land', 'Testing', 'Unit', 45.5, -73.6, 0.42, 123.4, 2024), ('ZY', 'ZYY', 'apitest-land-two', 'Apitest Land Two', 'Testing', 'Unit', 10, 10, null, null, null)`;66  await sql`insert into operators (id, slug, name, normalized_name, kind, hq_country_iso2, website) values (${OP}, 'apitest-operator', 'Apitest Operator', 'apitest operator', 'colocation', 'ZZ', 'https://example.test')`;67  await sql`insert into metros (id, slug, name, country_iso2, lat, lng, aliases) values (${MET}, 'apitest-metro', 'Apitest Metro', 'ZZ', 45.5, -73.6, '{"Apitestville"}')`;68  await sql`insert into facilities (id, slug, name, normalized_name, operator_id, metro_id, country_iso2, city, lat, lng, geo_precision, status, facility_type, it_capacity_mw, total_power_mw, utility_capacity_mw, is_ai, ai_evidence, confidence, completeness, opened_on, last_verified, source_count)69    values (${F1}, 'apitest-one', 'Apitest One Data Center', 'apitest one', ${OP}, ${MET}, 'ZZ', 'Montréal-Test', 45.5017, -73.5673, 'exact', 'operational', 'colocation', 12.5, 20, 40, true, 'confirmed', 'high', 80, '2019-05', now(), 2)`;70  await sql`insert into facilities (id, slug, name, normalized_name, operator_id, metro_id, country_iso2, city, lat, lng, geo_precision, status, facility_type, planned_power_mw, confidence, completeness, opened_on)71    values (${F2}, 'apitest-two', 'Apitest Two Campus', 'apitest two', ${OP}, ${MET}, 'ZZ', 'Montréal-Test', 45.52, -73.58, 'city', 'under_construction', 'hyperscale', 100, 'moderate', 40, '2027')`;72  await sql`insert into facilities (id, slug, name, normalized_name, operator_id, country_iso2, city, lat, lng, geo_precision, status, facility_type, confidence, completeness, source_count)73    values (${F3}, 'apitest-three', 'Apitest Three Restricted', 'apitest three', ${OP}, 'ZY', 'Restricted City', 10.01, 10.01, 'city', 'operational', 'colocation', 'moderate', 20, 1)`;74  await sql`insert into projects (id, slug, name, normalized_name, operator_id, facility_id, metro_id, country_iso2, city, lat, lng, geo_precision, status, announced_on, construction_started_on, expected_opening, planned_mw, is_ai, ai_evidence, project_class, evidence_level)75    values (${PRJ}, 'apitest-project', 'Apitest Expansion', 'apitest expansion', ${OP}, ${F2}, ${MET}, 'ZZ', 'Montréal-Test', 45.51, -73.57, 'city', 'under_construction', '2025-01', '2026-03', '2027-Q2', 100, true, 'likely', 'EXPANSION', 'strong')`;76  await sql`insert into events (id, entity_type, entity_id, event_type, old_value, new_value, source_id, url, title, significance, confidence, review_status, country_iso2, operator_id, metro_id, fingerprint, is_ai)77    values (${EVT}, 'facility', ${F1}, 'capacity_changed', '10'::jsonb, '12.5'::jsonb, ${SRC}, 'https://example.test/one', 'Apitest One capacity 10 → 12.5 MW', 80, 'high', 'auto', 'ZZ', ${OP}, ${MET}, 'apitest:evt:1', true)`;78  // a connector run with one provenance row (winner), one claim and one event — for /runs/:id/changes and rollback79  await sql`insert into connector_runs (id, connector_id, task, started_at, finished_at, status, stats) values (${RUN}, 'apitest', 'crawl', now() - interval '1 hour', now() - interval '50 minutes', 'ok', '{"discovered": 3, "fetched": 3, "created": 1, "changed": 1, "updated": 0, "failed": 0}'::jsonb)`;80  await sql`insert into provenance (id, entity_type, entity_id, field, value, source_id, connector_id, url, first_observed, last_observed, retrieved_at, confidence, is_current, is_winner, run_id)81    values (${PROV_OLD}, 'facility', ${F1}, 'itCapacityMw', '10'::jsonb, ${SRC}, 'apitest', 'https://example.test/one-old', now() - interval '30 days', now() - interval '2 days', now() - interval '2 days', 'moderate', true, false, null),82           (${PROV_RUN}, 'facility', ${F1}, 'itCapacityMw', '12.5'::jsonb, ${SRC}, 'apitest', 'https://example.test/one', now() - interval '1 hour', now() - interval '1 hour', now() - interval '1 hour', 'high', true, true, ${RUN}),83           (${PROV_F3}, 'facility', ${F3}, 'name', '"Apitest Three Restricted"'::jsonb, ${SRC_R}, 'apitest-restricted', 'https://restricted.test/three', now(), now(), now(), 'moderate', true, true, null)`;84  await sql`insert into claims (id, subject_type, subject_id, predicate, value, unit, scope, scope_reason, source_id, connector_id, url, published_at, confidence, authority_tier, evidence_text, status, run_id)85    values (${CLAIM}, 'facility', ${F1}, 'it_capacity_mw', 12.5, 'MW', 'facility', 'regex:facility', ${SRC}, 'apitest', 'https://example.test/one', '2026-09', 'high', 'B', 'The facility offers 12.5 MW of IT capacity.', 'current', ${RUN})`;86  await sql`insert into events (id, entity_type, entity_id, event_type, new_value, source_id, url, title, significance, confidence, review_status, country_iso2, operator_id, fingerprint, run_id)87    values (${EVT_RUN}, 'facility', ${F1}, 'facility_updated', '{"itCapacityMw": 12.5}'::jsonb, ${SRC}, 'https://example.test/one', 'Apitest One updated', 30, 'moderate', 'auto', 'ZZ', ${OP}, 'apitest:evt:2', ${RUN})`;88  await sql`insert into quality_flags (id, entity_type, entity_id, code, severity, field, message, priority, status, dedupe_key) values (${FLAG}, 'facility', ${F1}, 'mw_change_5x', 'warn', 'itCapacityMw', 'apitest flag', 999, 'open', 'apitest:flag:1')`;89  const { buildApp } = await import("./app.js");90  app = await buildApp({ logger: false, underPressure: false });91  await app.ready();92});9394afterAll(async () => {95  await cleanup();96  await app?.close();97  const { closeDb } = await import("@dci/db");98  const { closeRedis } = await import("./redis.js");99  const { closeQueues } = await import("./queues.js");100  await Promise.allSettled([closeQueues(), closeRedis(), closeDb()]);101});102103const slugs = (arr: Array<{ slug: string }>) => arr.map((f) => f.slug).filter((s) => s.startsWith("apitest"));104105describe("system", () => {106  it("health and ready", async () => {107    const h = await app.inject({ url: "/api/health" });108    expect(h.statusCode).toBe(200);109    expect(h.json().ok).toBe(true);110    const r = await app.inject({ url: "/api/ready" });111    expect(r.statusCode).toBe(200);112    expect(r.json().db).toBe(true);113  });114  it("metrics are prometheus text", async () => {115    const m = await app.inject({ url: "/api/metrics" });116    expect(m.statusCode).toBe(200);117    expect(m.body).toContain("dci_api_requests_total");118  });119  it("openapi document is served with response schemas", async () => {120    const o = await app.inject({ url: "/api/v1/openapi.json" });121    expect(o.statusCode).toBe(200);122    const paths = o.json().paths;123    expect(paths["/api/v1/datacenters"]).toBeDefined();124    expect(paths["/api/v1/nearby"]).toBeDefined();125    const ok = paths["/api/v1/datacenters"].get.responses["200"];126    expect(ok).toBeDefined();127    const schema = ok.content?.["application/json"]?.schema ?? ok;128    expect(schema.properties?.data ?? schema["x-example"] ?? schema.description).toBeDefined();129  });130});131132describe("envelope, ETag and 404", () => {133  it("wraps list responses and sets caching headers + sources", async () => {134    const res = await app.inject({ url: "/api/v1/datacenters?country=ZZ&sort=mw" });135    expect(res.statusCode).toBe(200);136    const body = res.json();137    expect(Array.isArray(body.data)).toBe(true);138    expect(body.meta.total).toBe(2);139    expect(body.meta.generatedAt).toBeTruthy();140    expect(body.data[0].slug).toBe("apitest-two"); // planned 100 MW sorts first (mw = COALESCE(it,total,planned))141    expect(body.data[1].operator).toEqual({ id: OP, slug: "apitest-operator", name: "Apitest Operator" });142    expect(body.data[1]).toMatchObject({ recordScope: "facility", aiEvidence: "confirmed", utilityCapacityMw: 40, sourceCount: 2 });143    expect(Array.isArray(body.sources)).toBe(true);144    expect(body.sources.some((s: { id: string; redistribution: string }) => s.id === SRC && s.redistribution === "attribution")).toBe(true);145    expect(res.headers.etag).toMatch(/^W\//);146    expect(res.headers["cache-control"]).toContain("s-maxage=120");147    const again = await app.inject({ url: "/api/v1/datacenters?country=ZZ&sort=mw", headers: { "if-none-match": String(res.headers.etag) } });148    expect(again.statusCode).toBe(304);149  });150  it("filters work (status, ai, min_mw, has_geo, operator, metro)", async () => {151    const a = await app.inject({ url: "/api/v1/datacenters?country=ZZ&status=operational" });152    expect(a.json().data.map((f: { slug: string }) => f.slug)).toEqual(["apitest-one"]);153    const b = await app.inject({ url: "/api/v1/datacenters?operator=apitest-operator&min_mw=50" });154    expect(b.json().data.map((f: { slug: string }) => f.slug)).toEqual(["apitest-two"]);155    const c = await app.inject({ url: "/api/v1/datacenters?metro=apitest-metro&ai=1" });156    expect(c.json().data.map((f: { slug: string }) => f.slug)).toEqual(["apitest-one"]);157    const d = await app.inject({ url: "/api/v1/datacenters?country=ZZ&opened_from=2020" });158    expect(d.json().data.map((f: { slug: string }) => f.slug)).toEqual(["apitest-two"]);159  });160  it("returns a consistent 404 JSON", async () => {161    for (const url of ["/api/v1/datacenters/nope", "/api/v1/operators/nope", "/api/v1/countries/nope", "/api/v1/metros/nope", "/api/v1/projects/nope", "/api/v1/events/nope", "/api/v1/rankings/nope", "/api/v1/sitemap/nope", "/api/v1/download/nope", "/api/nothing-here"]) {162      const r = await app.inject({ url });163      expect(r.statusCode, url).toBe(404);164      expect(typeof r.json().error, url).toBe("string");165    }166  });167  it("rejects bad query params with 400 + issues", async () => {168    const r = await app.inject({ url: "/api/v1/datacenters?min_mw=abc" });169    expect(r.statusCode).toBe(400);170    expect(r.json().details[0].path).toBe("min_mw");171  });172});173174describe("details", () => {175  it("facility detail has the full FacilityDetail shape", async () => {176    const r = await app.inject({ url: "/api/v1/datacenters/apitest-one" });177    expect(r.statusCode).toBe(200);178    const d = r.json().data;179    expect(d.id).toBe(F1);180    expect(d.metro.slug).toBe("apitest-metro");181    // legacy `nearby` is capped at the 8 closest rows: real Montréal facilities may outrank the fixture — shape only182    expect(Array.isArray(d.nearby)).toBe(true);183    expect(d.nearby.length).toBeLessThanOrEqual(8);184    expect(d.projects.map((p: { slug: string }) => p.slug)).toEqual(["apitest-project"]); // same operator + metro185    expect(d.events.length).toBe(2);186    expect(d.sourceHistory[0].kind).toBeTruthy();187    expect(Array.isArray(d.provenance)).toBe(true);188    expect(d.provenance.find((p: { isWinner: boolean; field: string }) => p.field === "itCapacityMw" && p.isWinner).value).toBe(12.5);189    expect(d.certifications).toEqual([]);190    // API 2.0 fields191    expect(d.buildings).toEqual([]);192    expect(d.tenants).toEqual([]);193    expect(d.claims.length).toBe(1);194    expect(d.claims[0]).toMatchObject({ id: CLAIM, predicate: "it_capacity_mw", label: "IT capacity", scope: "facility", isWinner: true, authorityTier: "B" });195    expect(d.capacityHistory.length).toBeGreaterThanOrEqual(3); // 2 observations + 1 claim + capacity_changed event196    expect(d.capacityHistory.some((h: { kind: string }) => h.kind === "changed")).toBe(true);197    expect(d.nearbyInfrastructure.radiusKm).toBe(25);198    expect(slugs(d.nearbyInfrastructure.facilities)).toEqual(["apitest-two"]);199    expect(slugs(d.nearbyInfrastructure.projects)).toEqual(["apitest-project"]);200    expect(d.nearbyInfrastructure.landingStations).toEqual([]);201    expect(typeof d.nearbyInfrastructure.note).toBe("string");202    expect(d.powerContext.utilityCapacityMw).toBe(40);203    expect(d.powerContext.countryEnergy).toMatchObject({ renewableShare: 0.42, electricityTwh: 123.4, statsYear: 2024 });204    expect(typeof d.powerContext.note).toBe("string");205    expect(d.dataQuality).toMatchObject({ sourceCount: 1, primarySourceCount: 1, claimsTotal: 1, claimsCurrent: 1, pendingDuplicate: false });206    expect(d.dataQuality.openFlags.some((f: { code: string }) => f.code === "mw_change_5x")).toBe(true);207    const byId = await app.inject({ url: `/api/v1/datacenters/${F1}` });208    expect(byId.json().data.slug).toBe("apitest-one");209    const small = await app.inject({ url: "/api/v1/datacenters/apitest-one?radius_km=1" });210    expect(small.json().data.nearbyInfrastructure.radiusKm).toBe(1);211  });212  it("facility history, claims and provenance endpoints", async () => {213    const h = (await app.inject({ url: "/api/v1/datacenters/apitest-one/history" })).json().data;214    expect(h.entityType).toBe("facility");215    expect(h.entityId).toBe(F1);216    expect(h.fields.itCapacityMw.length).toBeGreaterThanOrEqual(2);217    expect(h.changes.some((c: { eventId: string }) => c.eventId === EVT)).toBe(true);218    const c = (await app.inject({ url: "/api/v1/datacenters/apitest-one/claims" })).json().data;219    expect(c.map((x: { id: string }) => x.id)).toEqual([CLAIM]);220    const p = (await app.inject({ url: `/api/v1/datacenters/${F1}/provenance` })).json().data;221    expect(p.filter((x: { field: string }) => x.field === "itCapacityMw").length).toBe(2);222    expect((await app.inject({ url: "/api/v1/datacenters/nope/history" })).statusCode).toBe(404);223  });224  it("operator, country, metro and project details aggregate the fixture", async () => {225    const op = (await app.inject({ url: "/api/v1/operators/apitest-operator" })).json().data;226    expect(op.facilityCount).toBe(3);227    expect(op.countries.find((c: { iso2: string }) => c.iso2 === "ZZ")).toMatchObject({ iso2: "ZZ", facilityCount: 2, knownMw: 12.5 });228    expect(op.statusBreakdown).toEqual({ operational: 2, under_construction: 1 });229    expect(op.recentEvents.length).toBe(2);230    expect(op.pipeline.operational).toMatchObject({ count: 2, mw: 12.5 });231    expect(op.pipeline.construction).toMatchObject({ count: 1, mw: 100 });232    expect(op.pipeline.projects).toEqual([{ status: "under_construction", count: 1, mw: 100 }]);233    expect(op.velocity.windows.map((w: { label: string }) => w.label)).toEqual(["12m", "3y", "5y"]);234    expect(op.velocity.countriesOverTime.length).toBeGreaterThan(0);235    expect(op.topCountries[0]).toMatchObject({ iso2: "ZZ", facilityCount: 2 });236    expect(op.topCountries[0].share).toBeCloseTo(2 / 3, 2);237    expect(slugs(op.aiFacilities)).toEqual(["apitest-one"]);238    expect(Array.isArray(op.corporateEvents)).toBe(true);239    expect(op.dataQuality.sourceCount).toBe(0);240    const co = (await app.inject({ url: "/api/v1/countries/zz" })).json().data;241    expect(co).toMatchObject({ iso2: "ZZ", facilityCount: 2, operationalCount: 1, constructionCount: 1, knownMw: 12.5, constructionMw: 100, mwCoverage: 1, projectCount: 1 });242    // known MW = operational figures only: the 100 MW planned for the 2027 campus is pipeline, not known capacity243    expect(co.growth).toEqual([{ year: 2019, facilities: 1, knownMw: 12.5 }, { year: 2027, facilities: 2, knownMw: 12.5 }]);244    expect(co.topOperators[0].slug).toBe("apitest-operator");245    expect(co.energy).toMatchObject({ renewableShare: 0.42, electricityTwh: 123.4, statsYear: 2024, gridCarbonIntensity: null });246    expect(co.energy.note).toMatch(/national/i);247    expect(co.pipeline.construction.count).toBe(1);248    expect(co.coverage).toMatchObject({ key: "ZZ", facilities: 2, capacityCoverage: 1, anyLocationCoverage: 1, openingDateCoverage: 1 });249    expect(slugs(co.aiFacilities)).toEqual(["apitest-one"]);250    expect(co.aiProjects.map((p: { slug: string }) => p.slug)).toEqual(["apitest-project"]);251    expect(co.gridConstraints).toEqual([]);252    expect(co.ixps).toEqual([]);253    const me = (await app.inject({ url: "/api/v1/metros/apitest-metro" })).json().data;254    expect(me.facilityCount).toBe(2);255    expect(me.projectCount).toBe(1);256    expect(me.constraints).toEqual([]);257    expect(me.concentration.operatorCount).toBe(1);258    expect(me.concentration.facilities.hhi).toBe(10000);259    expect(me.concentration.knownMw.hhi).toBe(10000);260    expect(me.momentum.window).toBe("12m");261    expect(me.momentum.projectsEnteredConstruction).toBe(1);262    expect(me.pipeline.operational.count).toBe(1);263    expect(me.openingTimeline).toEqual([{ year: 2019, opened: 1, openedMw: 12.5 }, { year: 2027, opened: 1, openedMw: null }]);264    expect(me.coverage.facilities).toBe(2);265    expect(me.gridConstraints).toEqual([]);266    const pr = (await app.inject({ url: "/api/v1/projects/apitest-project" })).json().data;267    expect(pr.facility.slug).toBe("apitest-two");268    expect(pr.timeline).toEqual([]);269    expect(pr).toMatchObject({ projectClass: "EXPANSION", evidenceLevel: "strong", aiEvidence: "likely", isAi: true, geoPrecision: "city", constructionStartedOn: "2026-03" });270    const stages = Object.fromEntries(pr.stages.map((s: { stage: string; date: string | null; reached: boolean; current: boolean }) => [s.stage, s]));271    expect(stages.announced).toMatchObject({ date: "2025-01", reached: true, current: false });272    expect(stages.under_construction).toMatchObject({ date: "2026-03", reached: true, current: true });273    expect(stages.operational).toMatchObject({ reached: false, current: false });274    expect(pr.velocityDays.announcedToConstruction).toBe(Math.round((Date.UTC(2026, 2, 1) - Date.UTC(2025, 0, 1)) / 86_400_000));275    expect(pr.velocityDays.constructionToOpening).toBeNull();276    expect(pr.claims).toEqual([]);277    expect(pr.nearbyInfrastructure).not.toBeNull();278    expect(slugs(pr.nearbyInfrastructure.facilities)).toEqual(["apitest-one", "apitest-two"]);279    expect(Array.isArray(pr.relatedEvents)).toBe(true);280    expect(pr.dataQuality.claimsTotal).toBe(0);281    expect(pr.campus).toBeNull();282    const ph = (await app.inject({ url: "/api/v1/projects/apitest-project/history" })).json().data;283    expect(ph.entityType).toBe("project");284    expect((await app.inject({ url: "/api/v1/projects/apitest-project/claims" })).json().data).toEqual([]);285  });286  it("list endpoints include the fixture and carry sources", async () => {287    expect((await app.inject({ url: "/api/v1/countries" })).json().data.some((c: { iso2: string }) => c.iso2 === "ZZ")).toBe(true);288    expect((await app.inject({ url: "/api/v1/operators?q=apitest" })).json().data[0].facilityCount).toBe(3);289    const pj = (await app.inject({ url: "/api/v1/projects?country=ZZ&ai=1" })).json();290    expect(pj.meta.total).toBe(1);291    expect(Array.isArray(pj.sources)).toBe(true);292    const ev = (await app.inject({ url: "/api/v1/events?country=ZZ&type=capacity_changed" })).json();293    expect(ev.data[0]).toMatchObject({ id: EVT, entity: { slug: "apitest-one", name: "Apitest One Data Center" }, operator: { slug: "apitest-operator" }, metro: { slug: "apitest-metro" }, isAi: true, significanceBand: "major", evidenceCount: 1, sourceName: "Apitest Source", sourceKind: "operator" });294    expect(ev.sources.map((s: { id: string }) => s.id)).toContain(SRC);295    const major = (await app.inject({ url: "/api/v1/events?country=ZZ&significance=major&ai=1&source_kind=operator&q=capacity" })).json();296    expect(major.data.map((e: { id: string }) => e.id)).toEqual([EVT]);297    const none = (await app.inject({ url: "/api/v1/events?country=ZZ&significance=minor" })).json();298    expect(none.data.map((e: { id: string }) => e.id)).toEqual([EVT_RUN]);299    expect((await app.inject({ url: "/api/v1/sitemap/facilities" })).json().data.some((x: { slug: string }) => x.slug === "apitest-one")).toBe(true);300  });301});302303describe("map", () => {304  it("selects mode by zoom", async () => {305    const { mapMode } = await import("./repositories/map.js");306    expect(mapMode(0)).toBe("country");307    expect(mapMode(4.9)).toBe("country");308    expect(mapMode(5)).toBe("grid");309    expect(mapMode(8)).toBe("grid");310    expect(mapMode(9)).toBe("points");311  });312  it("country clusters at low zoom", async () => {313    const r = await app.inject({ url: "/api/v1/map?zoom=3&country=ZZ" });314    expect(r.statusCode).toBe(200);315    const d = r.json().data;316    expect(d.mode).toBe("clusters");317    expect(d.clusters).toEqual([{ key: "ZZ", lat: 45.5, lng: -73.6, count: 2, mw: 112.5, statuses: { operational: 1, under_construction: 1 }, label: "Apitest Land" }]);318  });319  it("grid clusters at mid zoom and points at high zoom", async () => {320    const g = (await app.inject({ url: "/api/v1/map?zoom=6&country=ZZ" })).json().data;321    expect(g.mode).toBe("clusters");322    expect(g.clusters[0].key.startsWith("6:")).toBe(true);323    expect(g.total).toBe(2);324    const p = (await app.inject({ url: "/api/v1/map?zoom=12&bbox=-74,45,-73,46&country=ZZ" })).json().data;325    expect(p.mode).toBe("points");326    expect(p.points.map((x: { slug: string }) => x.slug)).toEqual(["apitest-two", "apitest-one"]);327    expect(p.points[1]).toMatchObject({ n: "Apitest One Data Center", o: "Apitest Operator", s: "operational", t: "colocation", mw: 12.5, p: "exact", ai: 1, c: "ZZ", y: 2019 });328    const outside = (await app.inject({ url: "/api/v1/map?zoom=12&bbox=10,10,11,11&country=ZZ" })).json().data;329    expect(outside.points).toEqual([]);330  });331  it("layers, density and time machine", async () => {332    const pr = (await app.inject({ url: "/api/v1/map?zoom=12&bbox=-74,45,-73,46&country=ZZ&layer=projects" })).json();333    expect(pr.statusCode ?? 200).toBe(200);334    expect(pr.data.layer).toBe("projects");335    const overlay = pr.data.overlay ?? [];336    expect(overlay.find((x: { slug: string }) => x.slug === "apitest-project")).toMatchObject({ k: "project", s: "under_construction", p: "city" });337    const dens = (await app.inject({ url: "/api/v1/map?zoom=6&country=ZZ&density=facilities" })).json().data;338    expect(dens.mode).toBe("density");339    expect(dens.density.view).toBe("facilities");340    expect(dens.density.cells.length).toBeGreaterThanOrEqual(1);341    expect(dens.density.max).toBeGreaterThanOrEqual(1);342    expect(dens.density.cells.reduce((a: number, c: { n: number }) => a + c.n, 0)).toBe(2);343    const yr = (await app.inject({ url: "/api/v1/map?zoom=3&country=ZZ&year=2020" })).json().data;344    expect(yr.year).toBe(2020);345    expect(yr.yearCoverage).toBe(1);346    expect(yr.clusters[0].count).toBe(1); // apitest-one opened 2019; apitest-two opens 2027347    const bad = await app.inject({ url: "/api/v1/map?zoom=3&layer=nope" });348    expect(bad.statusCode).toBe(400);349  });350});351352describe("search", () => {353  it("finds the fixture and interprets filters", async () => {354    const r = await app.inject({ url: "/api/v1/search?q=apitest%20operational" });355    expect(r.statusCode).toBe(200);356    const d = r.json().data;357    expect(d.interpreted.status).toBe("operational");358    expect(d.hits.some((h: { type: string; slug: string }) => h.type === "facility" && h.slug === "apitest-one")).toBe(true);359    expect(d.hits.some((h: { type: string }) => h.type === "operator")).toBe(true);360    expect(d.facilities.items.map((f: { slug: string }) => f.slug).filter((s: string) => s.startsWith("apitest"))).toEqual(["apitest-one", "apitest-three"]);361    const city = d.hits.find((h: { type: string }) => h.type === "city");362    expect(city).toBeUndefined(); // "apitest" is not a city363    expect(Array.isArray(r.json().sources)).toBe(true);364  });365  it("city hits link to the facility list", async () => {366    const d = (await app.inject({ url: "/api/v1/search?q=Montr%C3%A9al-Test" })).json().data;367    const city = d.hits.find((h: { type: string }) => h.type === "city");368    expect(city.href).toContain("/datacenters?q=");369  });370  it("interprets project, max MW and year phrases", async () => {371    const d = (await app.inject({ url: "/api/v1/search?q=projects%20under%20500%20MW%20opening%202027" })).json().data;372    expect(d.interpreted).toMatchObject({ entity: "project", maxMw: 500, year: 2027 });373  });374});375376describe("intelligence endpoints", () => {377  it("/nearby returns layers around a point (empty layers are honest)", async () => {378    const r = await app.inject({ url: "/api/v1/nearby?lat=45.5017&lng=-73.5673&radius_km=10" });379    expect(r.statusCode).toBe(200);380    const d = r.json().data;381    expect(d.center).toEqual({ lat: 45.5017, lng: -73.5673 });382    expect(d.radiusKm).toBe(10);383    expect(slugs(d.facilities)).toEqual(["apitest-one", "apitest-two"]);384    expect(d.facilities[0].distanceKm).toBe(0);385    expect(slugs(d.projects)).toEqual(["apitest-project"]);386    expect(d.metros.some((m: { slug: string }) => m.slug === "apitest-metro")).toBe(true);387    expect(d.landingStations).toEqual([]);388    expect(d.substations).toEqual([]);389    expect(d.powerPlants).toEqual([]);390    expect(d.note).toMatch(/never/i);391    expect((await app.inject({ url: "/api/v1/nearby?lat=95&lng=0" })).statusCode).toBe(400);392    expect((await app.inject({ url: "/api/v1/nearby?lng=0" })).statusCode).toBe(400);393    expect((await app.inject({ url: "/api/v1/nearby?lat=45.5&lng=-73.6&radius_km=500" })).statusCode).toBe(400); // > 200 km is rejected, not clamped394    const typed = (await app.inject({ url: "/api/v1/nearby?lat=45.5&lng=-73.6&radius_km=200&types=metros" })).json().data;395    expect(typed.radiusKm).toBe(200);396    expect(typed.facilities).toEqual([]);397    expect(typed.metros.some((m: { slug: string }) => m.slug === "apitest-metro")).toBe(true);398  });399  it("/pulse is deterministic and validates the window", async () => {400    const r = await app.inject({ url: "/api/v1/pulse?window=7d" });401    expect(r.statusCode).toBe(200);402    const d = r.json().data;403    expect(d.window).toBe("7d");404    expect(typeof d.since).toBe("string");405    expect(d.newProjects).toBeGreaterThanOrEqual(1); // the fixture project was created now406    expect(d.eventsTotal).toBeGreaterThanOrEqual(2);407    expect(d.capacityChanges).toBeGreaterThanOrEqual(1);408    for (const k of ["projectsEnteredConstruction", "facilitiesOpened", "cloudRegionsAnnounced", "powerAgreements", "gridConstraintEvents", "acquisitions", "financingEvents", "newFacilitiesIndexed"]) expect(typeof d[k], k).toBe("number");409    expect(Array.isArray(d.majorEvents)).toBe(true);410    // top-15 lists: the fixture cannot be expected to rank in an 11 000-event database — check shape only411    expect(Array.isArray(d.byCountry)).toBe(true);412    if (d.byCountry.length) expect(d.byCountry[0]).toMatchObject({ iso2: expect.any(String), slug: expect.any(String), events: expect.any(Number), newProjects: expect.any(Number) });413    expect(Array.isArray(d.byOperator)).toBe(true);414    if (d.byOperator.length) expect(d.byOperator[0]).toMatchObject({ slug: expect.any(String), events: expect.any(Number) });415    expect((await app.inject({ url: "/api/v1/pulse?window=1y" })).statusCode).toBe(400);416    expect((await app.inject({ url: "/api/v1/pulse" })).json().data.window).toBe("24h");417  });418  it("/explore filters facilities and projects, rejects unknown keys", async () => {419    const f = (await app.inject({ url: "/api/v1/explore?entity=facilities&country=ZZ&per_page=10" })).json().data;420    expect(f.total).toBe(2);421    expect(f.items.map((x: { slug: string }) => x.slug).sort()).toEqual(["apitest-one", "apitest-two"]);422    expect(Object.fromEntries(f.facets.status.map((s: { key: string; count: number }) => [s.key, s.count]))).toEqual({ operational: 1, under_construction: 1 });423    expect(f.facets.country[0]).toMatchObject({ key: "ZZ", name: "Apitest Land", count: 2 });424    expect(f.facets.operator[0]).toMatchObject({ key: "apitest-operator", count: 2 });425    expect(f.facets.ai.some((a: { key: string; count: number }) => a.key === "confirmed" && a.count === 1)).toBe(true);426    expect(f.charts.byStatus.find((s: { key: string }) => s.key === "operational")).toMatchObject({ count: 1, mw: 12.5 });427    expect(f.charts.byYear.map((y: { year: number }) => y.year)).toEqual([2019, 2027]);428    expect(f.map.total).toBe(2);429    expect(f.map.degraded).toBe(false);430    expect(f.mwCoverage).toBe(1);431    const ai = (await app.inject({ url: "/api/v1/explore?country=ZZ&ai=confirmed" })).json().data;432    expect(ai.items.map((x: { slug: string }) => x.slug)).toEqual(["apitest-one"]);433    const p = (await app.inject({ url: "/api/v1/explore?entity=projects&country=ZZ&min_mw=50" })).json().data;434    expect(p.total).toBe(1);435    expect(p.items[0].slug).toBe("apitest-project");436    expect(p.map.points[0]).toMatchObject({ k: "project" });437    const bad = await app.inject({ url: "/api/v1/explore?bogus=1" });438    expect(bad.statusCode).toBe(400);439    expect((await app.inject({ url: "/api/v1/explore?entity=nope" })).statusCode).toBe(400);440  });441  it("/coverage reports per scope and per source", async () => {442    const r = await app.inject({ url: "/api/v1/coverage" });443    expect(r.statusCode).toBe(200);444    const d = r.json().data;445    expect(d.global.facilities).toBeGreaterThan(2);446    expect(d.global.capacityCoverage).toBeGreaterThan(0);447    expect(d.global.capacityCoverage).toBeLessThanOrEqual(1);448    const zz = d.countries.find((c: { key: string }) => c.key === "ZZ");449    expect(zz).toMatchObject({ facilities: 2, capacityCoverage: 1, operatorCoverage: 1, preciseLocationCoverage: 0.5, statusCoverage: 1, openingDateCoverage: 1, projects: 1, projectsWithLocation: 1 });450    expect(d.fields.some((f: { field: string }) => /it.?capacity|itCapacityMw/i.test(f.field))).toBe(true);451    expect(d.bySourceKind.some((k: { kind: string }) => k.kind === "operator")).toBe(true);452    const s = (await app.inject({ url: "/api/v1/coverage/sources" })).json().data;453    const src = s.find((x: { id: string }) => x.id === SRC);454    expect(src).toMatchObject({ kind: "operator", recordsContributed: 1, fieldsContributed: 2, uniqueRecords: 1, redistribution: "attribution", license: "CC-BY-4.0" });455    expect(src.lastSuccessfulCrawl).toBeTruthy();456  });457  it("/ai-infrastructure, /power, /connectivity and /time-machine", async () => {458    const ai = (await app.inject({ url: "/api/v1/ai-infrastructure" })).json().data;459    expect(ai.stats.confirmed).toBeGreaterThanOrEqual(1);460    expect(ai.stats.facilities).toBeGreaterThanOrEqual(ai.stats.confirmed);461    expect(ai.stats.projects).toBeGreaterThanOrEqual(1);462    expect(ai.evidenceNote).toMatch(/confirmed/);463    expect(ai.topOperators.length).toBeGreaterThan(0);464    expect(ai.topOperators[0]).toHaveProperty("plannedMw");465    expect(Array.isArray(ai.pipelineByStage)).toBe(true);466    const pw = (await app.inject({ url: "/api/v1/power" })).json().data;467    // top-50 by MW: a 40 MW fixture is outranked by the ≥ 200 MW projects in the database — check shape + ordering468    expect(pw.largeLoads.length).toBeGreaterThan(0);469    expect(pw.largeLoads.every((l: { kind: string; mw: number }) => ["utility_capacity", "grid_connection", "planned_load"].includes(l.kind) && l.mw > 0)).toBe(true);470    expect(pw.largeLoads.every((l: { mw: number }, i: number, a: Array<{ mw: number }>) => i === 0 || a[i - 1]!.mw >= l.mw)).toBe(true);471    expect(Array.isArray(pw.gridConstraints)).toBe(true);472    // countryEnergy lists the 60 largest markets: honest energy context, never an estimated carbon intensity473    expect(pw.countryEnergy.length).toBeGreaterThan(0);474    expect(pw.countryEnergy.every((c: { energy: { gridCarbonIntensity: unknown; note: string }; facilities: number }) => c.energy.gridCarbonIntensity === null && typeof c.energy.note === "string" && c.facilities > 0)).toBe(true);475    expect(typeof pw.note).toBe("string");476    expect(Array.isArray(pw.utilities)).toBe(true);477    const cn = (await app.inject({ url: "/api/v1/connectivity" })).json().data;478    expect(cn.landingStations).toEqual([]);479    expect(typeof cn.note).toBe("string");480    expect(Array.isArray(cn.ixps)).toBe(true);481    expect(Array.isArray(cn.byMetro)).toBe(true);482    const tm = (await app.inject({ url: "/api/v1/time-machine" })).json().data;483    expect(tm.frames.length).toBeGreaterThan(0);484    const y2019 = tm.frames.find((f: { year: number }) => f.year === 2019);485    expect(y2019.opened).toBeGreaterThanOrEqual(1);486    expect(typeof tm.earliestReliableYear).toBe("number");487    expect(tm.openingDateCoverage).toBeGreaterThan(0);488    expect(tm.openingDateCoverage).toBeLessThanOrEqual(1);489    expect(tm.note).toMatch(/opening date/i);490  });491  it("/compare/operators validates and compares", async () => {492    expect((await app.inject({ url: "/api/v1/compare/operators?slugs=apitest-operator" })).statusCode).toBe(400);493    expect((await app.inject({ url: "/api/v1/compare/operators?slugs=apitest-operator,does-not-exist" })).statusCode).toBe(404);494    const other = (await app.inject({ url: "/api/v1/operators?per_page=1&sort=facilities" })).json().data[0];495    const r = await app.inject({ url: `/api/v1/compare/operators?slugs=apitest-operator,${other.slug}` });496    expect(r.statusCode).toBe(200);497    const d = r.json().data;498    expect(d.operators.length).toBe(2);499    const me = d.operators.find((o: { slug: string }) => o.slug === "apitest-operator");500    expect(me.pipeline.construction.count).toBe(1);501    expect(me.countriesList.sort()).toEqual(["ZY", "ZZ"]);502    expect(me.aiCount).toBe(1);503    expect(me.velocity.windows.length).toBe(3);504  });505  it("/docs-meta lists endpoints with examples", async () => {506    const d = (await app.inject({ url: "/api/v1/docs-meta" })).json().data;507    const nearby = d.find((e: { path: string }) => e.path === "/api/v1/nearby");508    expect(nearby).toMatchObject({ method: "GET", group: "nearby" });509    expect(nearby.params.some((p: { name: string }) => p.name === "lat")).toBe(true);510    expect(nearby.example.curl).toContain("/api/v1/nearby");511    expect(typeof nearby.example.python).toBe("string");512    expect(d.some((e: { path: string }) => e.path.startsWith("/api/v1/download/"))).toBe(true);513    expect(d.some((e: { path: string; method: string }) => e.path === "/api/v1/watchlist" && e.method === "POST")).toBe(true);514  });515});516517describe("dashboard", () => {518  it("has the API 2.0 sections", async () => {519    const r = await app.inject({ url: "/api/v1/dashboard" });520    expect(r.statusCode).toBe(200);521    const d = r.json().data;522    expect(d.stats.facilities).toBeGreaterThan(0);523    expect(d.pulse.window).toBe("24h");524    expect(Array.isArray(d.fastestGrowingMetros)).toBe(true);525    expect(d.majorProjects.length).toBeGreaterThan(0);526    expect(d.majorProjects.every((p: { plannedMw: number }) => p.plannedMw >= 100)).toBe(true);527    expect(Array.isArray(d.operatorExpansion)).toBe(true);528    expect(Array.isArray(d.powerEvents)).toBe(true);529    expect(Array.isArray(d.gridConstraints)).toBe(true);530    expect(d.coverage.key).toBe("global");531    expect(typeof d.ingestion.runs24h).toBe("number");532    expect(d.ingestion.connectorsTotal).toBeGreaterThanOrEqual(0);533  });534});535536describe("download", () => {537  it("lists datasets", async () => {538    const r = await app.inject({ url: "/api/v1/download/datasets" });539    expect(r.statusCode).toBe(200);540    const d = r.json().data;541    const fac = d.find((x: { key: string }) => x.key === "facilities");542    expect(fac.formats).toEqual(expect.arrayContaining(["csv", "json", "geojson"]));543    expect(fac.rows).toBeGreaterThan(0);544    expect(fac.excludedSources.some((s: { id: string }) => s.id === SRC_R)).toBe(true);545    expect(d.map((x: { key: string }) => x.key)).toEqual(expect.arrayContaining(["projects", "operators", "events", "cloud-regions", "ixps", "countries", "markets"]));546  });547  it("streams CSV with a header row, license gating and content-disposition", async () => {548    const r = await app.inject({ url: "/api/v1/download/facilities?format=csv&country=ZZ" });549    expect(r.statusCode).toBe(200);550    expect(String(r.headers["content-type"])).toContain("text/csv");551    expect(String(r.headers["content-disposition"])).toMatch(/attachment; filename="?dci-facilities-\d{4}-\d{2}-\d{2}\.csv"?/);552    const lines = r.body.trim().split(/\r?\n/);553    expect(lines[0]!.startsWith("id,slug,name")).toBe(true);554    expect(lines.length).toBe(3);555    expect(r.body).toContain("apitest-one");556    // the only source of apitest-three forbids redistribution → the row is excluded and the source listed557    const zy = await app.inject({ url: "/api/v1/download/facilities?format=csv&country=ZY" });558    expect(zy.statusCode).toBe(200);559    expect(zy.body.trim().split(/\r?\n/).length).toBe(1);560    expect(String(zy.headers["x-dci-excluded-sources"] ?? "")).toContain(SRC_R);561    const js = await app.inject({ url: "/api/v1/download/facilities?format=json&country=ZY" });562    expect(js.json().meta.excludedSources.some((s: { id: string }) => s.id === SRC_R)).toBe(true);563    const gj = await app.inject({ url: "/api/v1/download/projects?format=geojson&country=ZZ" });564    expect(gj.statusCode).toBe(200);565    expect(gj.json().type).toBe("FeatureCollection");566    expect(gj.json().features[0].geometry).toEqual({ type: "Point", coordinates: [-73.57, 45.51] });567    expect((await app.inject({ url: "/api/v1/download/facilities?format=xml" })).statusCode).toBe(400);568  });569});570571describe("watchlist", () => {572  it("is private to an httpOnly cookie token and feeds events", async () => {573    const first = await app.inject({ url: "/api/v1/watchlist" });574    expect(first.statusCode).toBe(200);575    expect(first.json().data).toEqual([]);576    const setCookie = String(first.headers["set-cookie"] ?? "");577    expect(setCookie).toMatch(/dci_watch=/);578    expect(setCookie).toMatch(/HttpOnly/i);579    expect(first.headers["cache-control"]).toBe("no-store");580    const cookie = setCookie.split(";")[0]!;581    const add = await app.inject({ method: "POST", url: "/api/v1/watchlist", headers: { cookie }, payload: { entityType: "facility", slug: "apitest-one" } });582    expect([200, 201]).toContain(add.statusCode);583    expect(add.json().data).toMatchObject({ entityType: "facility", entityId: F1, slug: "apitest-one", name: "Apitest One Data Center" });584    const id = add.json().data.id as string;585    const list = (await app.inject({ url: "/api/v1/watchlist", headers: { cookie } })).json().data;586    expect(list.map((w: { entityId: string }) => w.entityId)).toEqual([F1]);587    const other = (await app.inject({ url: "/api/v1/watchlist" })).json().data;588    expect(other).toEqual([]); // a different visitor sees nothing589    const feed = (await app.inject({ url: "/api/v1/watchlist/feed", headers: { cookie } })).json().data;590    expect(feed.map((e: { id: string }) => e.id).sort()).toEqual([EVT, EVT_RUN].sort());591    expect((await app.inject({ method: "POST", url: "/api/v1/watchlist", headers: { cookie }, payload: { entityType: "nope", slug: "x" } })).statusCode).toBe(400);592    expect((await app.inject({ method: "POST", url: "/api/v1/watchlist", headers: { cookie }, payload: { entityType: "facility", slug: "does-not-exist" } })).statusCode).toBe(404);593    const del = await app.inject({ method: "DELETE", url: `/api/v1/watchlist/${id}`, headers: { cookie } });594    expect(del.json().data.deleted).toBe(true);595    expect((await app.inject({ url: "/api/v1/watchlist", headers: { cookie } })).json().data).toEqual([]);596  });597});598599describe("admin", () => {600  it("requires the token", async () => {601    expect((await app.inject({ url: "/api/admin/connectors" })).statusCode).toBe(401);602    expect((await app.inject({ url: "/api/admin/connectors", headers: { "x-dci-admin-token": "wrong" } })).statusCode).toBe(401);603    const ok = await app.inject({ url: "/api/admin/connectors", headers: ADMIN });604    expect(ok.statusCode).toBe(200);605    expect(Array.isArray(ok.json().data)).toBe(true);606    expect(ok.headers["cache-control"]).toBe("no-store");607  });608  it("rejects the placeholder token with 503", async () => {609    const { getEnv } = await import("./env.js");610    const saved = process.env.DCI_ADMIN_TOKEN;611    process.env.DCI_ADMIN_TOKEN = "change-me";612    resetEnv();613    expect(getEnv().adminToken).toBeNull();614    const r = await app.inject({ url: "/api/admin/connectors", headers: { "x-dci-admin-token": "change-me" } });615    expect(r.statusCode).toBe(503);616    process.env.DCI_ADMIN_TOKEN = saved;617    resetEnv();618    expect(getEnv().adminToken).toBe(TOKEN);619  });620  it("constant-time token compare helper", async () => {621    const { tokenMatches } = await import("./routes/admin/index.js");622    expect(tokenMatches("abc", "abc")).toBe(true);623    expect(tokenMatches("abc", "abd")).toBe(false);624    expect(tokenMatches(undefined, "abc")).toBe(false);625    expect(tokenMatches("abc", null)).toBe(false);626  });627  it("connector health carries the full DTO", async () => {628    const rows = (await app.inject({ url: "/api/admin/connectors", headers: ADMIN })).json().data;629    if (rows.length) {630      const c = rows[0];631      for (const k of ["quarantine", "consecutiveFailures", "urlsDiscovered", "urlsFetched", "newDocs", "changedDocs", "recordsCreated", "recordsModified", "rejectedClaims", "httpErrors", "antiBotEscalations", "scrapflyRequests", "firecrawlRequests"]) expect(c, k).toHaveProperty(k);632      expect(["ok", "degraded", "failing", "paused", "never_run", "blocked", "schema_change", "no_new_content", "quarantine"]).toContain(c.health);633    }634  });635  it("quality overview, flags and review queue", async () => {636    const r = await app.inject({ url: "/api/admin/quality", headers: ADMIN });637    expect(r.statusCode).toBe(200);638    const d = r.json().data;639    expect(d.openFlags).toBeGreaterThanOrEqual(1);640    expect(d.bySeverity.warn).toBeGreaterThanOrEqual(1);641    expect(d.byCode.some((c: { code: string }) => c.code === "mw_change_5x")).toBe(true);642    expect(d.reviewQueue[0]).toMatchObject({ id: FLAG, entity: { slug: "apitest-one", name: "Apitest One Data Center" }, priority: 999 });643    expect(d.largest.operationalFacilityMw.length).toBeGreaterThan(0);644    expect(d.largest.operationalFacilityMw[0]).toHaveProperty("scope");645    expect(d.largest.projectMw.length).toBeGreaterThan(0);646    for (const k of ["suspiciousMw", "suspiciousInvestment", "unscopedClaims", "claimsInReview", "possibleDuplicates", "projectFalsePositiveCandidates", "unverifiedLargeProjects", "facilitiesMissingCountry", "projectsWithoutFacility", "orphanOperators"]) expect(typeof d[k], k).toBe("number");647    const flags = (await app.inject({ url: "/api/admin/quality/flags?status=open&code=mw_change_5x&min_priority=900", headers: ADMIN })).json();648    expect(flags.data.map((f: { id: string }) => f.id)).toContain(FLAG);649    const res = await app.inject({ method: "POST", url: `/api/admin/quality/flags/${FLAG}/resolve`, headers: ADMIN, payload: { resolution: "checked against the operator page" } });650    expect(res.statusCode).toBe(200);651    expect(res.json().data).toMatchObject({ id: FLAG, status: "resolved", resolution: "checked against the operator page" });652    const dq = (await app.inject({ url: "/api/v1/datacenters/apitest-one" })).json().data.dataQuality;653    expect(dq.openFlags.some((f: { code: string }) => f.code === "mw_change_5x")).toBe(false);654    expect((await app.inject({ method: "POST", url: "/api/admin/quality/flags/flg_nope/dismiss", headers: ADMIN, payload: {} })).statusCode).toBe(404);655  });656  it("claims listing and status change", async () => {657    const list = (await app.inject({ url: `/api/admin/claims?subject_type=facility&subject_id=${F1}`, headers: ADMIN })).json();658    expect(list.data.map((c: { id: string }) => c.id)).toEqual([CLAIM]);659    const r = await app.inject({ method: "POST", url: `/api/admin/claims/${CLAIM}/status`, headers: ADMIN, payload: { status: "review", reason: "double-check scope" } });660    expect(r.statusCode).toBe(200);661    expect(r.json().data.claim).toMatchObject({ id: CLAIM, status: "review" });662    expect(r.json().data.flagged).toBe(false);663    await app.inject({ method: "POST", url: `/api/admin/claims/${CLAIM}/status`, headers: ADMIN, payload: { status: "current" } });664    expect((await app.inject({ method: "POST", url: `/api/admin/claims/${CLAIM}/status`, headers: ADMIN, payload: { status: "bogus" } })).statusCode).toBe(400);665  });666  it("worker proxies answer 502 when the worker is unreachable", async () => {667    const g = await app.inject({ url: "/api/admin/data-gaps", headers: ADMIN });668    expect(g.statusCode).toBe(502);669    expect(typeof g.json().error).toBe("string");670    const t = await app.inject({ url: "/api/admin/documents/doc_nope/trace", headers: ADMIN });671    expect(t.statusCode).toBe(502);672  });673  it("hides a project and excludes it everywhere, then unhides", async () => {674    const hide = await app.inject({ method: "POST", url: `/api/admin/projects/${PRJ}/hide`, headers: ADMIN, payload: { reason: "executive appointment, not a project" } });675    expect(hide.statusCode).toBe(200);676    expect((await app.inject({ url: "/api/v1/projects?country=ZZ" })).json().meta.total).toBe(0);677    expect((await app.inject({ url: "/api/v1/projects/apitest-project" })).statusCode).toBe(404);678    expect((await app.inject({ url: "/api/v1/countries/zz" })).json().data.projectCount).toBe(0);679    expect((await app.inject({ url: "/api/v1/metros/apitest-metro" })).json().data.projectCount).toBe(0);680    expect((await app.inject({ url: "/api/v1/explore?entity=projects&country=ZZ" })).json().data.total).toBe(0);681    const overlay = (await app.inject({ url: "/api/v1/map?zoom=12&bbox=-74,45,-73,46&country=ZZ&layer=projects" })).json().data.overlay ?? [];682    expect(overlay.some((x: { slug: string }) => x.slug === "apitest-project")).toBe(false);683    expect((await app.inject({ url: "/api/v1/pulse?window=7d" })).json().data.newProjects).toBe(0 + (await sql`select count(*)::int as n from projects where hidden = false and merged_into is null and created_at >= now() - interval '7 days'`)[0]!.n);684    const prov = await sql`select value from provenance where entity_type = 'project' and entity_id = ${PRJ} and field = 'hidden' and source_id = 'src_manual'`;685    expect(prov.length).toBe(1);686    const unhide = await app.inject({ method: "POST", url: `/api/admin/projects/${PRJ}/unhide`, headers: ADMIN, payload: {} });687    expect(unhide.statusCode).toBe(200);688    expect((await app.inject({ url: "/api/v1/projects?country=ZZ" })).json().meta.total).toBe(1);689    expect((await app.inject({ method: "POST", url: `/api/admin/projects/${PRJ}/hide`, headers: ADMIN, payload: {} })).statusCode).toBe(400);690  });691  it("patches a project with provenance", async () => {692    const r = await app.inject({ method: "PATCH", url: `/api/admin/projects/${PRJ}`, headers: ADMIN, payload: { planned_mw: 120, expected_opening: "2027-Q3", note: "operator update" } });693    expect(r.statusCode).toBe(200);694    const d = (await app.inject({ url: "/api/v1/projects/apitest-project" })).json().data;695    expect(d.plannedMw).toBe(120);696    expect(d.expectedOpening).toBe("2027-Q3");697    const prov = await sql`select field from provenance where entity_type = 'project' and entity_id = ${PRJ} and source_id = 'src_manual' and is_current order by field`;698    expect(prov.map((p) => p.field)).toEqual(expect.arrayContaining(["expectedOpening", "plannedMw"]));699    expect((await app.inject({ method: "PATCH", url: `/api/admin/projects/${PRJ}`, headers: ADMIN, payload: { expected_opening: "someday" } })).statusCode).toBe(400);700    expect((await app.inject({ method: "PATCH", url: `/api/admin/projects/${PRJ}`, headers: ADMIN, payload: { bogus: 1 } })).statusCode).toBe(400);701  });702  it("links a building to its campus (containment)", async () => {703    const r = await app.inject({ method: "POST", url: `/api/admin/facilities/${F1}/parent`, headers: ADMIN, payload: { parentId: F2 } });704    expect(r.statusCode).toBe(200);705    const child = (await app.inject({ url: "/api/v1/datacenters/apitest-one" })).json().data;706    expect(child.recordScope).toBe("building");707    expect(child.parentFacility).toEqual({ id: F2, slug: "apitest-two", name: "Apitest Two Campus" });708    const parent = (await app.inject({ url: "/api/v1/datacenters/apitest-two" })).json().data;709    expect(parent.recordScope).toBe("campus");710    expect(parent.buildings.map((b: { slug: string }) => b.slug)).toEqual(["apitest-one"]);711    // containment-aware aggregate: the campus row with a building is not counted; the building keeps its own MW712    const co = (await app.inject({ url: "/api/v1/countries/zz" })).json().data;713    expect(co.pipeline.operational).toMatchObject({ count: 1, mw: 12.5 });714    expect(co.pipeline.construction.count).toBe(0);715    expect((await app.inject({ method: "POST", url: `/api/admin/facilities/${F2}/parent`, headers: ADMIN, payload: { parentId: F1 } })).statusCode).toBe(400); // cycle716    const undo = await app.inject({ method: "POST", url: `/api/admin/facilities/${F1}/parent`, headers: ADMIN, payload: { parentId: null } });717    expect(undo.statusCode).toBe(200);718    expect((await app.inject({ url: "/api/v1/datacenters/apitest-one" })).json().data.recordScope).toBe("facility");719  });720  it("run changes and rollback", async () => {721    const ch = (await app.inject({ url: `/api/admin/runs/${RUN}/changes`, headers: ADMIN })).json().data;722    expect(ch.provenance.length).toBe(1);723    expect(ch.claims.map((c: { id: string }) => c.id)).toEqual([CLAIM]);724    expect(ch.events.map((e: { id: string }) => e.id)).toEqual([EVT_RUN]);725    expect((await app.inject({ url: "/api/admin/runs/run_nope/changes", headers: ADMIN })).statusCode).toBe(404);726    const rb = await app.inject({ method: "POST", url: `/api/admin/runs/${RUN}/rollback`, headers: ADMIN, payload: {} });727    expect(rb.statusCode).toBe(200);728    const claim = await sql`select status, rejection_reason from claims where id = ${CLAIM}`;729    expect(claim[0]).toMatchObject({ status: "rejected", rejection_reason: "rollback" });730    const prov = await sql`select id, is_current, is_winner from provenance where entity_id = ${F1} and field = 'itCapacityMw' order by id`;731    expect(prov.find((p) => p.id === PROV_RUN)).toMatchObject({ is_current: false, is_winner: false });732    expect(prov.find((p) => p.id === PROV_OLD)).toMatchObject({ is_current: true, is_winner: true });733    const ev = await sql`select review_status from events where id = ${EVT_RUN}`;734    expect(ev[0]!.review_status).toBe("rejected");735    const ids = (await app.inject({ url: "/api/v1/events?country=ZZ&per_page=100" })).json().data.map((e: { id: string }) => e.id);736    expect(ids).toContain(EVT);737    expect(ids).not.toContain(EVT_RUN);738  });739  it("curates a facility with provenance + event", async () => {740    const r = await app.inject({ method: "PATCH", url: `/api/admin/facilities/${F1}`, headers: ADMIN, payload: { total_power_mw: 25, status: "expansion", note: "test" } });741    expect(r.statusCode).toBe(200);742    const body = r.json().data;743    expect(body.changed.map((c: { field: string }) => c.field).sort()).toEqual(["status", "totalPowerMw"]);744    expect(body.facility.totalPowerMw).toBe(25);745    const prov = await sql`select field, value, source_id from provenance where entity_id = ${F1} and source_id = 'src_manual' order by field`;746    expect(prov.map((p) => p.field)).toEqual(expect.arrayContaining(["status", "totalPowerMw"]));747    const ev = await sql`select event_type, significance from events where entity_id = ${F1} and source_id = 'src_manual' and event_type = 'status_changed'`;748    expect(ev[0]).toMatchObject({ event_type: "status_changed", significance: 90 });749    const bad = await app.inject({ method: "PATCH", url: `/api/admin/facilities/${F1}`, headers: ADMIN, payload: { status: "not-a-status" } });750    expect(bad.statusCode).toBe(400);751  });752  it("merges a duplicate facility", async () => {753    const r = await app.inject({ method: "POST", url: `/api/admin/facilities/${F2}/merge`, headers: ADMIN, payload: { into: "apitest-one" } });754    expect(r.statusCode).toBe(200);755    expect(r.json().data).toMatchObject({ into: F1, merged: F2 });756    const list = (await app.inject({ url: "/api/v1/datacenters?country=ZZ" })).json();757    expect(list.meta.total).toBe(1);758    const follow = (await app.inject({ url: "/api/v1/datacenters/apitest-two" })).json().data;759    expect(follow.id).toBe(F1);760    expect(follow.aliases).toContain("Apitest Two Campus");761    const prj = (await app.inject({ url: "/api/v1/projects/apitest-project" })).json().data;762    expect(prj.facility.id).toBe(F1);763  });764  it("validates connector yaml", async () => {765    const good = await app.inject({ method: "POST", url: "/api/admin/devtool/validate-config", headers: ADMIN, payload: { yaml: "id: apitest\nname: Apitest\ndomain: example.test\nkind: operator\n" } });766    expect(good.json().data.ok).toBe(true);767    const bad = await app.inject({ method: "POST", url: "/api/admin/devtool/validate-config", headers: ADMIN, payload: { yaml: "id: Bad Id\nname: x\n" } });768    expect(bad.json().data.ok).toBe(false);769    expect(bad.json().data.errors.length).toBeGreaterThan(0);770  });771  it("previews an extractor against inline html", async () => {772    const html = `<html><head><title>Apitest DC1</title><script type="application/ld+json">{"@type":"Place","name":"Apitest DC1","address":{"streetAddress":"1 Rue Test","addressLocality":"Montréal","addressCountry":"CA"},"geo":{"latitude":45.5,"longitude":-73.6}}</script></head><body><h1>Apitest DC1</h1><div class="specs">Total power 36 MW</div></body></html>`;773    const r = await app.inject({ method: "POST", url: "/api/admin/devtool/preview", headers: ADMIN, payload: { html, url: "https://example.test/data-centers/dc1", extractor: { kind: "facility", key: "apitest:{code}", fields: { name: "h1", code: { selector: "h1", regex: "\\b(DC\\d+)\\b" }, city: { jsonld: "Place", jsonPath: "address.addressLocality" }, countryIso2: { jsonld: "Place", jsonPath: "address.addressCountry", transform: "country" }, lat: { jsonld: "Place", jsonPath: "geo.latitude", transform: "float" }, lng: { jsonld: "Place", jsonPath: "geo.longitude", transform: "float" }, totalPowerMw: { selector: ".specs", regex: "(\\d+(?:\\.\\d+)?\\s*MW)", transform: "mw" } } } } });774    expect(r.statusCode).toBe(200);775    const d = r.json().data;776    expect(d.records.length).toBe(1);777    expect(d.records[0].key).toBe("apitest:DC1");778    expect(d.entities[0]).toMatchObject({ entityType: "facility", name: "Apitest DC1", city: "Montréal", countryIso2: "CA", totalPowerMw: 36 });779    expect(d.entities[0].geo).toMatchObject({ lat: 45.5, lng: -73.6 });780    expect(d.validation).toMatchObject({ total: 1, valid: 1, rejected: 0 });781  });782  it("maintenance body is strict", async () => {783    const bad = await app.inject({ method: "POST", url: "/api/admin/maintenance/rankings", headers: ADMIN, payload: { evil: "x" } });784    expect(bad.statusCode).toBe(400);785  });786});787