[object Object]

← back to Homesonspec

integration tests (pipeline e2e, only-writer invariant, review-approve flow) + 7 Playwright e2e specs, all green

0ba88221128f05f11e0f7d441cf7d659ae8ac05e · 2026-07-22 11:41:41 -0700 · Steve Abrams

Files touched

Diff

commit 0ba88221128f05f11e0f7d441cf7d659ae8ac05e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 11:41:41 2026 -0700

    integration tests (pipeline e2e, only-writer invariant, review-approve flow) + 7 Playwright e2e specs, all green
---
 apps/workers/package.json                 |   3 +-
 apps/workers/src/pipeline.itest.ts        | 171 ++++++++++++++++++++++++++++++
 apps/workers/vitest.integration.config.ts |  11 ++
 apps/workers/vitest.integration.setup.ts  |   8 ++
 playwright.config.ts                      |  25 +++++
 tests/end-to-end/admin.spec.ts            |  20 ++++
 tests/end-to-end/consumer.spec.ts         |  46 ++++++++
 7 files changed, 283 insertions(+), 1 deletion(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index b400501..ff44da6 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -7,7 +7,8 @@
     "pipeline": "tsx src/cli.ts",
     "start": "tsx src/index.ts",
     "test": "vitest run --passWithNoTests",
-    "typecheck": "tsc --noEmit"
+    "typecheck": "tsc --noEmit",
+    "test:integration": "vitest run --config vitest.integration.config.ts"
   },
   "dependencies": {
     "@spechomes/database": "workspace:*",
diff --git a/apps/workers/src/pipeline.itest.ts b/apps/workers/src/pipeline.itest.ts
new file mode 100644
index 0000000..7be956e
--- /dev/null
+++ b/apps/workers/src/pipeline.itest.ts
@@ -0,0 +1,171 @@
+import { beforeAll, describe, expect, it } from "vitest";
+import { prisma } from "@spechomes/database";
+import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
+import { publishStagedRecord } from "@spechomes/publisher";
+import { runPipeline, validateStage } from "./pipeline";
+
+/**
+ * Integration: the full fixture pipeline against spechomes_test.
+ * Proves raw ≠ published separation, verdict gating, idempotence,
+ * community auto-creation from staged records, and review-queue flow.
+ */
+
+async function resetDb() {
+  // Truncate in FK-safe order.
+  const tables = [
+    "ValidationEvent", "ReviewItem", "SourceEvidence", "StagedRecord", "RawSnapshot",
+    "ChangeEvent", "CorrectionReport", "Lead", "Incentive", "InventoryHome", "Lot",
+    "SalesOffice", "FloorPlan", "Community", "Division", "SourceRegistry", "Builder",
+  ];
+  for (const table of tables) {
+    await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
+  }
+}
+
+beforeAll(async () => {
+  expect(process.env.DATABASE_URL).toMatch(/spechomes_test/);
+  await resetDb();
+  // Minimal FK targets: builder + fixture source. NO community — the
+  // pipeline must create it from the staged community record.
+  const builder = await prisma.builder.create({
+    data: { slug: "meridian-homes", name: "Meridian Homes", isDemo: true },
+  });
+  await prisma.sourceRegistry.create({
+    data: {
+      key: "meridian-homes-fixtures",
+      name: "Meridian Homes (fixture pages)",
+      builderId: builder.id,
+      collectionMethod: "FIXTURE",
+      mediaRights: "NONE",
+    },
+  });
+});
+
+describe("fixture pipeline end-to-end", () => {
+  it("publishes community + homes + incentives from fixtures", async () => {
+    const summary = await runPipeline(meridianHomesAdapter);
+    expect(summary.pages).toBe(5);
+    expect(summary.staged).toBe(6);
+    expect(summary.published).toBe(6);
+    expect(summary.rejected).toBe(0);
+    expect(summary.errors).toEqual([]);
+
+    // Community was CREATED by the publish stage from the staged record.
+    const community = await prisma.community.findFirst({ where: { name: "Cedar Bend" } });
+    expect(community).not.toBeNull();
+    expect(community!.city).toBe("Leander");
+    expect(community!.state).toBe("TX");
+    expect(community!.isDemo).toBe(true);
+
+    const homes = await prisma.inventoryHome.findMany({ where: { status: "PUBLISHED" } });
+    expect(homes).toHaveLength(3);
+    for (const home of homes) {
+      expect(home.verificationLabel).toBe("DEMONSTRATION"); // FIXTURE method
+      expect(home.isDemo).toBe(true);
+    }
+
+    // Null-price home stays null — never guessed.
+    const silverleaf = homes.find((h) => h.street === "124 Silverleaf Pass");
+    expect(silverleaf?.price).toBeNull();
+
+    // Evidence re-pointed at published entities.
+    const evidence = await prisma.sourceEvidence.count({
+      where: { entityType: "INVENTORY_HOME", entityId: { not: null } },
+    });
+    expect(evidence).toBeGreaterThan(0);
+  });
+
+  it("is idempotent — unchanged bytes stage nothing new", async () => {
+    const summary = await runPipeline(meridianHomesAdapter);
+    expect(summary.changed).toBe(0);
+    expect(summary.staged).toBe(0);
+    expect(await prisma.inventoryHome.count()).toBe(3);
+  });
+
+  it("publish stage refuses non-validated records (only-writer invariant)", async () => {
+    const source = await prisma.sourceRegistry.findUniqueOrThrow({
+      where: { key: "meridian-homes-fixtures" },
+    });
+    const staged = await prisma.stagedRecord.create({
+      data: {
+        sourceId: source.id,
+        entityType: "INVENTORY_HOME",
+        canonicalKey: "not-validated-key",
+        payload: {},
+        hints: { builderSlug: "meridian-homes", communityName: "Cedar Bend" },
+        extractorVersion: "test",
+        status: "EXTRACTED",
+      },
+    });
+    await expect(publishStagedRecord(staged.id)).rejects.toThrow(/refusing to publish/);
+    expect(await prisma.inventoryHome.count()).toBe(3); // nothing leaked
+  });
+
+  it("big price change → needs_review + ReviewItem; approval publishes + ChangeEvent", async () => {
+    const source = await prisma.sourceRegistry.findUniqueOrThrow({
+      where: { key: "meridian-homes-fixtures" },
+    });
+    const juniper = await prisma.inventoryHome.findFirstOrThrow({
+      where: { street: "118 Juniper Draw" },
+    });
+    const fv = (value: unknown, raw: string | null) => ({
+      value, raw, evidenceText: raw, sourceUrl: "fixture://test", confidence: value === null ? 0 : 1,
+    });
+    // Same canonical identity, price dropped 20%.
+    const newPrice = Math.round(Number(juniper.price) * 0.8);
+    const staged = await prisma.stagedRecord.create({
+      data: {
+        sourceId: source.id,
+        entityType: "INVENTORY_HOME",
+        canonicalKey: juniper.canonicalKey,
+        payload: {
+          street: fv(juniper.street, juniper.street),
+          city: fv("Leander", "Leander"),
+          state: fv("TX", "TX"),
+          zip: fv("78641", "78641"),
+          price: fv(newPrice, `$${newPrice}`),
+          beds: fv(4, "4 Beds"),
+          bathsTotal: fv(3, "3 Baths"),
+          sqft: fv(2415, "2,415 sq ft"),
+          lat: fv(30.5791, "30.5791"),
+          lon: fv(-97.8519, "-97.8519"),
+        },
+        hints: {
+          builderSlug: "meridian-homes",
+          communityName: "Cedar Bend",
+          address: juniper.street,
+          lotNumber: "118",
+          builderInventoryId: "MH-1118",
+          lat: 30.5791,
+          lon: -97.8519,
+          planName: "The Juniper",
+        },
+        extractorVersion: "test",
+        status: "EXTRACTED",
+      },
+    });
+
+    const { verdict } = await validateStage(staged.id, "meridian-homes-fixtures");
+    expect(verdict).toBe("needs_review");
+    const review = await prisma.reviewItem.findFirst({
+      where: { stagedRecordId: staged.id, status: "OPEN" },
+    });
+    expect(review).not.toBeNull();
+    expect(review!.reason).toMatch(/price.big-change/);
+
+    // Price unchanged until a human approves.
+    const before = await prisma.inventoryHome.findUniqueOrThrow({ where: { id: juniper.id } });
+    expect(Number(before.price)).toBe(Number(juniper.price));
+
+    // Human approval → publish updates the home + records a ChangeEvent.
+    await prisma.stagedRecord.update({ where: { id: staged.id }, data: { status: "APPROVED" } });
+    await publishStagedRecord(staged.id);
+    const after = await prisma.inventoryHome.findUniqueOrThrow({ where: { id: juniper.id } });
+    expect(Number(after.price)).toBe(newPrice);
+    const change = await prisma.changeEvent.findFirst({
+      where: { entityType: "INVENTORY_HOME", entityId: juniper.id, field: "price" },
+    });
+    expect(change).not.toBeNull();
+    expect(change!.newValue).toBe(String(newPrice));
+  });
+});
diff --git a/apps/workers/vitest.integration.config.ts b/apps/workers/vitest.integration.config.ts
new file mode 100644
index 0000000..6bdfa66
--- /dev/null
+++ b/apps/workers/vitest.integration.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    include: ["src/**/*.itest.ts"],
+    setupFiles: ["./vitest.integration.setup.ts"],
+    // Pipeline stages hit a real (test) database — run serially.
+    fileParallelism: false,
+    testTimeout: 30_000,
+  },
+});
diff --git a/apps/workers/vitest.integration.setup.ts b/apps/workers/vitest.integration.setup.ts
new file mode 100644
index 0000000..fbaf171
--- /dev/null
+++ b/apps/workers/vitest.integration.setup.ts
@@ -0,0 +1,8 @@
+// Point the Prisma singleton at the TEST database before any test module
+// imports @spechomes/database. Refuses to run against a non-test DB.
+const testUrl =
+  process.env.DATABASE_URL_TEST ?? "postgresql://macstudio3@localhost/spechomes_test?host=/tmp";
+if (!/spechomes_test/.test(testUrl)) {
+  throw new Error(`integration tests require a *_test database, got: ${testUrl}`);
+}
+process.env.DATABASE_URL = testUrl;
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..b476e73
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+  testDir: "./tests/end-to-end",
+  timeout: 30_000,
+  retries: 0,
+  use: {
+    baseURL: "http://localhost:3100",
+  },
+  webServer: [
+    {
+      command: "pnpm --filter @spechomes/web dev",
+      url: "http://localhost:3100",
+      reuseExistingServer: true,
+      timeout: 60_000,
+    },
+    {
+      command: "pnpm --filter @spechomes/admin dev",
+      url: "http://localhost:3101",
+      reuseExistingServer: true,
+      timeout: 60_000,
+      // Admin returns 401 without creds — Playwright treats any response as "up".
+    },
+  ],
+});
diff --git a/tests/end-to-end/admin.spec.ts b/tests/end-to-end/admin.spec.ts
new file mode 100644
index 0000000..7b2d5dc
--- /dev/null
+++ b/tests/end-to-end/admin.spec.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/test";
+
+const ADMIN = "http://localhost:3101";
+const AUTH = "Basic " + Buffer.from("admin:REDACTED_ADMIN_PW").toString("base64");
+
+test("admin challenges without credentials (401)", async ({ request }) => {
+  const response = await request.get(ADMIN, { failOnStatusCode: false });
+  expect(response.status()).toBe(401);
+  expect(response.headers()["www-authenticate"]).toContain("Basic");
+});
+
+test("admin dashboard renders with credentials; cards show created chips", async ({ browser }) => {
+  const context = await browser.newContext({ extraHTTPHeaders: { Authorization: AUTH } });
+  const page = await context.newPage();
+  await page.goto(`${ADMIN}/sources`);
+  await expect(page.getByTestId("source-card").first()).toBeVisible({ timeout: 15_000 });
+  // Standing rule: every admin card shows a created date+time chip.
+  await expect(page.getByTestId("source-card").first().getByText("🕓")).toBeVisible();
+  await context.close();
+});
diff --git a/tests/end-to-end/consumer.spec.ts b/tests/end-to-end/consumer.spec.ts
new file mode 100644
index 0000000..d2f0522
--- /dev/null
+++ b/tests/end-to-end/consumer.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from "@playwright/test";
+
+test("search by city shows results and map markers", async ({ page }) => {
+  await page.goto("/search?q=Leander");
+  await expect(page.getByTestId("result-count")).toContainText(/\d+ homes/, { timeout: 15_000 });
+  await expect(page.getByTestId("home-card").first()).toBeVisible();
+  // Leaflet map container mounts client-side.
+  await expect(page.getByTestId("map").locator(".leaflet-container")).toBeVisible({ timeout: 10_000 });
+});
+
+test("price and beds filters change the result count", async ({ page }) => {
+  await page.goto("/search?q=Leander");
+  await expect(page.getByTestId("result-count")).toContainText(/\d+ homes/, { timeout: 15_000 });
+  const initial = await page.getByTestId("result-count").textContent();
+
+  await page.getByTestId("beds-5").click();
+  await expect(page.getByTestId("result-count")).not.toHaveText(initial ?? "", { timeout: 10_000 });
+});
+
+test("sort and density persist across reload (localStorage)", async ({ page }) => {
+  await page.goto("/search?q=Leander");
+  await page.getByTestId("sort-select").selectOption("price_desc");
+  await page.getByTestId("density-slider").fill("4");
+  await page.reload();
+  await expect(page.getByTestId("sort-select")).toHaveValue("price_desc");
+  await expect(page.getByTestId("density-slider")).toHaveValue("4");
+});
+
+test("home detail shows verification label, last-verified, and demo banner", async ({ page }) => {
+  await page.goto("/search?q=Leander");
+  await page.getByTestId("home-card").first().click();
+  await expect(page).toHaveURL(/\/homes\//);
+  await expect(page.getByTestId("verification-block")).toContainText(/Demonstration data|Verified/);
+  await expect(page.getByTestId("verification-block")).toContainText(/Last verified/);
+  await expect(page.getByText(/Demonstration inventory/)).toBeVisible();
+  await expect(page.getByTestId("evidence-table")).toBeVisible();
+});
+
+test("correction report control submits", async ({ page }) => {
+  await page.goto("/search?q=Leander");
+  await page.getByTestId("home-card").first().click();
+  await page.getByTestId("correction-toggle").click();
+  await page.getByTestId("correction-message").fill("e2e test: price looks stale");
+  await page.getByTestId("correction-submit").click();
+  await expect(page.getByTestId("correction-done")).toBeVisible({ timeout: 10_000 });
+});

← 73a5db3 generalize pipeline: hints column on StagedRecord, publisher  ·  back to Homesonspec  ·  pm2 ecosystem config, 9 ADRs, data-rights policy, source run 4dce3d2 →