T-HUB/apps/api/src/archive/archive-search.integration.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

256 lines
12 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
/**
* WP15 archive search + export against a live PostgreSQL, as sp_app (FORCE
* RLS binds). Uses its OWN database (ARCHIVE_SEARCH_IT_DATABASE_URL, plus
* ARCHIVE_SEARCH_IT_SUPER_DATABASE_URL for migrations/seeding) per the
* local-db-sol pattern: tenancy.integration.test.ts wipes every workspace in
* the shared DB and vitest runs files in parallel. Skipped when unset.
*/
const appUrl = process.env.ARCHIVE_SEARCH_IT_DATABASE_URL;
const ownerUrl = process.env.ARCHIVE_SEARCH_IT_SUPER_DATABASE_URL;
type Modules = {
service: typeof import("./archive.service.js");
"search-helpers": typeof import("./archive-search.js");
db: typeof import("../db/client.js");
};
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("archive search + export (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
let wsA: string;
let wsB: string;
let chA = "";
const chB = `@chB${suffix}`;
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
/** Seed tenant rows honestly: FORCE RLS applies to the owner too. */
async function inTenant(client: PoolClient, wsId: string, fn: () => Promise<void>): Promise<void> {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]);
await fn();
await client.query("COMMIT");
}
let seedSeq = 0;
/** remote_ref must equal channel_id || ':' || message_id; content_hash is sha256 hex. */
async function seedItem(client: PoolClient, wsId: string, item: {
channel: string; message: number; text: string; postedAt: Date; attribution?: Record<string, unknown>;
}): Promise<void> {
seedSeq += 1;
await client.query(
`INSERT INTO archived_feed_items
(workspace_id, remote_ref, channel_id, message_id, text_content, content_hash, posted_at, attribution)
VALUES ($1, $2 || ':' || $3::bigint::text, $2, $3::bigint, $4, encode(sha256($5::bytea), 'hex'), $6, $7::jsonb)`,
[wsId, item.channel, item.message, item.text, `seed-${suffix}-${seedSeq}`, item.postedAt,
JSON.stringify(item.attribution ?? {})],
);
}
beforeAll(async () => {
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
const { runMigrations } = await import("../db/migrate.js");
await runMigrations(join(process.cwd(), "migrations"));
m = {
service: await import("./archive.service.js"),
"search-helpers": await import("./archive-search.js"),
db: await import("../db/client.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
await asOwner(async (c) => {
const ws = async (name: string): Promise<string> =>
(await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id",
[name, `${name}-${suffix}`])).rows[0].id as string;
wsA = await ws("search-a");
wsB = await ws("search-b");
});
// Tenant data: distinct texts per tenant, overlap only inside wsA.
chA = `@chA${suffix}`;
await asOwner((c) => inTenant(c, wsA, async () => {
await seedItem(c, wsA, {
channel: chA, message: 1001,
text: "Water rights are a community responsibility, not a commodity for markets.",
postedAt: new Date("2026-01-10T09:00:00Z"),
attribution: { channelUsername: chA.slice(1), author: "Water Working Group" },
});
await seedItem(c, wsA, {
channel: chA, message: 1002,
text: "Totally unrelated post about bicycle repair cooperatives and tools.",
postedAt: new Date("2026-01-11T09:00:00Z"),
});
await seedItem(c, wsA, {
channel: chA, message: 1003,
text: "Water rights update: the hearing is postponed to February.",
postedAt: new Date("2026-01-12T09:00:00Z"),
});
}));
await asOwner((c) => inTenant(c, wsB, async () => {
await seedItem(c, wsB, {
channel: chB, message: 2001,
text: "Water rights are a community responsibility, not a commodity for markets.",
postedAt: new Date("2026-01-05T09:00:00Z"),
});
}));
});
afterAll(async () => {
if (ownerPool !== undefined) {
await asOwner(async (c) => {
await c.query("DELETE FROM workspaces WHERE id = ANY($1::uuid[])", [[wsA, wsB]]);
});
await ownerPool.end();
}
await m?.db.pool.end();
});
it("full-text search matches archived text (websearch_to_tsquery)", async () => {
const service = new m.service.ArchiveService(null as never);
const result = await service.search(wsA, { q: "water rights" });
expect(result.items).toHaveLength(2);
for (const item of result.items) {
expect(String(item.text)).toMatch(/water/i);
expect(item.remoteRef).toBeDefined();
}
// Ranked path: both matches, the denser one first is NOT guaranteed, but
// the update (1003) must not shadow the older match.
expect(result.items.map((i) => i.messageId).sort()).toEqual([1001, 1003]);
});
it("fuzzy typo tolerance: misspelled token still matches via pg_trgm", async () => {
const service = new m.service.ArchiveService(null as never);
const result = await service.search(wsA, { q: "watter rghts" });
expect(result.items.length).toBeGreaterThanOrEqual(1);
expect(result.items.map((i) => i.messageId).sort()).toEqual([1001, 1003]);
});
it("metadata search: channel id and attribution text are searchable", async () => {
const service = new m.service.ArchiveService(null as never);
const byChannel = await service.search(wsA, { q: chA });
expect(byChannel.items).toHaveLength(3);
const byAuthor = await service.search(wsA, { q: "Working Group" });
expect(byAuthor.items.map((i) => i.messageId)).toEqual([1001]);
});
it("RLS: search never crosses tenants even with shared text", async () => {
const service = new m.service.ArchiveService(null as never);
const a = await service.search(wsA, { q: "water rights" });
const b = await service.search(wsB, { q: "water rights" });
expect(a.items).toHaveLength(2);
expect(b.items).toHaveLength(1);
expect(b.items.every((i) => String(i.remoteRef).startsWith(chB))).toBe(true);
// Direct DB-level cross-tenant denial as sp_app.
const leaked = await m.db.withTenant(wsB, (client) => client.query(
"SELECT count(*)::int AS n FROM archived_feed_items WHERE workspace_id = $1", [wsA]));
expect(leaked.rows[0]?.n).toBe(0);
});
it("date + channel filters narrow the result set", async () => {
const service = new m.service.ArchiveService(null as never);
const from = await service.search(wsA, { q: "water", from: "2026-01-12" });
expect(from.items.map((i) => i.messageId)).toEqual([1003]);
const to = await service.search(wsA, { q: "water", to: "2026-01-10T23:59:59Z" });
expect(to.items.map((i) => i.messageId)).toEqual([1001]);
const channel = await service.search(wsA, { q: "bicycle", channelId: chA });
expect(channel.items.map((i) => i.messageId)).toEqual([1002]);
const none = await service.search(wsA, { q: "bicycle", channelId: `@nonesuch${suffix}` });
expect(none.items).toHaveLength(0);
});
it("keyset pagination walks the ranked result set without repeats or gaps", async () => {
const service = new m.service.ArchiveService(null as never);
const page1 = await service.search(wsA, { q: "water rights", limit: "1" });
expect(page1.items).toHaveLength(1);
expect(page1.nextCursor).not.toBeNull();
const page2 = await service.search(wsA, { q: "water rights", limit: "1", cursor: page1.nextCursor ?? "" });
expect(page2.items).toHaveLength(1);
expect(page2.nextCursor).toBeNull();
const seen = [...page1.items, ...page2.items].map((i) => i.messageId).sort();
expect(seen).toEqual([1001, 1003]);
// Bogus cursor is rejected, not silently ignored.
await expect(service.search(wsA, { q: "water", cursor: "bogus-cursor" }))
.rejects.toThrow(/invalid cursor/);
});
it("export streams every matching item with faithful content (json + csv)", async () => {
const service = new m.service.ArchiveService(null as never);
const json: string[] = [];
const format = await service.exportItems(wsA, { q: "water rights" }, (chunk) => json.push(chunk));
expect(format).toBe("json");
const parsed = JSON.parse(json.join("")) as Array<Record<string, unknown>>;
expect(parsed).toHaveLength(2);
expect(parsed.map((i) => i.messageId).sort()).toEqual([1001, 1003]);
expect(parsed.every((i) => typeof i.postedAt === "string" && typeof i.remoteRef === "string")).toBe(true);
const csv: string[] = [];
const csvFormat = await service.exportItems(wsA, { q: "water", format: "csv" }, (chunk) => csv.push(chunk));
expect(csvFormat).toBe("csv");
const csvText = csv.join("");
const lines = csvText.split("\r\n").filter((line) => line !== "");
expect(lines).toHaveLength(3); // header + 2 rows
const header = lines[0]!.split(",");
expect(header.slice(0, 4)).toEqual(["id", "remote_ref", "channel_id", "message_id"]);
const rows = lines.slice(1).map((line) => line.split(","));
for (const row of rows) {
expect(row).toHaveLength(header.length);
expect(UUID_SHAPE.test(row[0]!)).toBe(true);
expect(row[1]).toMatch(new RegExp(`^${chA}:[0-9]+$`));
}
const textCol = header.indexOf("text");
const texts = rows.map((row) => row[textCol]!).sort();
expect(texts[1]).toContain("hearing is postponed");
});
it("export CSV escaping: commas, quotes and newlines round-trip", async () => {
const service = new m.service.ArchiveService(null as never);
const hostile = 'He said "water, rights", then\nleft';
await asOwner((c) => inTenant(c, wsA, async () => {
await seedItem(c, wsA, {
channel: chA, message: 1004, text: hostile, postedAt: new Date("2026-01-13T09:00:00Z"),
});
}));
const csv: string[] = [];
await service.exportItems(wsA, { channelId: chA, format: "csv" }, (chunk) => csv.push(chunk));
const lines = csv.join("").split("\r\n").filter((l) => l !== "");
const header = lines[0]!.split(",");
const textCol = header.indexOf("text");
const rows = lines.slice(1).map((line) => line.split(","));
const hostileRow = rows.find((row) => row[textCol] === hostile);
expect(hostileRow).toBeDefined();
expect(hostileRow![textCol]).toBe(hostile);
expect(rows.every((row) => row.length === header.length)).toBe(true);
});
it("filter validation rejects garbage bounds, limits and formats", async () => {
const service = new m.service.ArchiveService(null as never);
await expect(service.search(wsA, { q: "water", from: "not-a-date" })).rejects.toThrow(/ISO-8601/);
await expect(service.search(wsA, { q: "water", from: "2026-02-01", to: "2026-01-01" })).rejects.toThrow(/after/);
await expect(service.search(wsA, { q: "water", limit: "0" })).rejects.toThrow(/between 1 and/);
await expect(service.search(wsA, { q: "water", limit: "1000" })).rejects.toThrow(/between 1 and/);
await expect(service.search(wsA, { q: "water", cursor: "bogus" })).rejects.toThrow(/invalid cursor/);
await expect(service.exportItems(wsA, { format: "xml" }, () => undefined)).rejects.toThrow(/csv or json/);
});
it("unindexed helper behavior: tokenization + CSV header shape", () => {
const helpers = m["search-helpers"];
expect(helpers.searchTokens("Watter, Rghts! @channel123")).toEqual(["watter", "rghts", "@channel123"]);
expect(helpers.searchTokens("ab xx ")).toEqual([]);
expect(helpers.ARCHIVE_CSV_HEADER).toMatch(/^id,remote_ref,channel_id,message_id,posted_at,revision,protected_content,text,storage_keys,attribution\r\n$/);
});
});
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;