T-HUB/apps/api/src/feeds/feeds.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

102 lines
6.5 KiB
TypeScript

import "reflect-metadata";
import { randomUUID } from "node:crypto";
import { Module, UnauthorizedException, ForbiddenException, type ExecutionContext, type INestApplication } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core";
import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { DemoController } from "../demo/demo.controller.js";
import { PERMISSION_KEY, RbacGuard } from "../rbac/rbac.guard.js";
import type { SessionPayload } from "../auth/session.js";
import { injectHttp } from "../connections/http-test-transport.js";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { feedRegistry } from "./feed-registry.js";
import { FeedsService, type FeedResponse } from "./feeds.service.js";
import { FeedsController } from "./feeds.controller.js";
import { MemoryFeedRedis } from "./testing.js";
const memory = vi.hoisted(() => ({ rows: new Map<string, { id: string; workspaceId: string; provider: string; channelId: string }>(), audits: [] as string[] }));
vi.mock("../db/client.js", () => ({
pool: {},
withTenant: async (workspaceId: string, callback: (client: { query(sql: string, values: unknown[]): Promise<{ rows: Array<{ id: string }>; rowCount: number }> }) => Promise<unknown>) => callback({
async query(sql, values) {
if (sql.startsWith("INSERT INTO connections")) {
const id = randomUUID();
memory.rows.set(id, { id, workspaceId, provider: String(values[1]), channelId: String(values[2]) });
return { rows: [{ id }], rowCount: 1 };
}
if (sql.includes("audit_append")) memory.audits.push(sql);
return { rows: [], rowCount: 1 };
},
}),
}));
const session: SessionPayload = { userId: randomUUID(), workspaceId: randomUUID(), role: "Owner", authzEpoch: 1, issuedAt: 0, expiresAt: 9_999_999_999 };
@Module({})
class TestModule {}
describe("HTTP demo connection → connector poll → GET feeds", () => {
let app: INestApplication;
let canView = true;
const registry = feedRegistry(new InMemoryCredentials());
const service = new FeedsService({
redis: new MemoryFeedRedis(), registry, random: () => 0, requestsPerSecond: () => 0.5,
workspaces: async () => [session.workspaceId],
connections: async (ws) => [...memory.rows.values()].filter((row) => row.workspaceId === ws).map((row) => ({ ...row, fields: {}, health: "needs_credentials", recentPublishes: [], credentialRotatedAt: null, createdAt: new Date().toISOString() })),
connection: async (ws, id): Promise<ConnectorConnection> => {
const row = memory.rows.get(id); if (!row || row.workspaceId !== ws) throw new Error("Missing row");
return { ...row, fields: {}, credentialId: { kind: "credential-ref", id }, createdAt: new Date() };
},
visible: async (s) => [...memory.rows.values()].filter((row) => row.workspaceId === s.workspaceId).map((row) => row.id),
audit: async () => { memory.audits.push("feeds.refresh"); },
});
beforeAll(async () => {
vi.spyOn(service, "onApplicationBootstrap").mockImplementation(() => undefined);
vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => {
const req = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: SessionPayload }>();
if (req.headers.authorization !== "Bearer test-session") throw new UnauthorizedException();
const permission = new Reflector().get<string>(PERMISSION_KEY, context.getHandler());
if (permission === "draft.view" && !canView) throw new ForbiddenException();
req.session = session; return true;
});
app = await NestFactory.create({ module: TestModule, controllers: [DemoController, FeedsController], providers: [{ provide: FeedsService, useValue: service }] }, { logger: false });
await app.init();
});
afterAll(async () => { await app?.close(); vi.restoreAllMocks(); });
const request = (path: string, method = "GET", body?: unknown, auth = true) => injectHttp(app.getHttpAdapter().getInstance(), `http://api.test${path}`, {
method, headers: { ...(auth ? { authorization: "Bearer test-session" } : {}), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body),
});
it("seeds through the real demo route without OAuth, then returns only feed-capable items", async () => {
const created = await request("/demo/connections", "POST", { provider: "mock-social", channelId: "demo-channel" });
expect(created.status).toBe(201);
const mockId = (await created.json() as { id: string }).id;
const other = await request("/demo/connections", "POST", { provider: "peertube", channelId: "video-channel" });
const otherId = (await other.json() as { id: string }).id;
const refreshed = await request("/feeds/refresh", "POST");
expect(refreshed.status).toBe(201);
expect(await refreshed.json()).toEqual({ polled: 1, skipped: 0, failed: 0 });
const response = await request("/feeds");
expect(response.status).toBe(200);
const feed = await response.json() as FeedResponse;
expect(feed.items).toHaveLength(12);
expect(feed.items.every((item) => item.connectionId === mockId && item.provider === "mock-social")).toBe(true);
expect(feed.meta.excludedConnections).toEqual([{ id: otherId, provider: "peertube", reason: "feed_not_supported" }]);
expect(feed.meta.connections[0]?.handle).toBe("mock@social");
expect(memory.audits.filter((entry) => entry === "feeds.refresh")).toHaveLength(1);
await service.pollWorkspace(session.workspaceId);
expect(memory.audits.filter((entry) => entry === "feeds.refresh")).toHaveLength(1);
const direct = await request(`/feeds/${mockId}?limit=2`);
expect((await direct.json() as FeedResponse).items).toHaveLength(2);
const absent = await request(`/feeds/${otherId}`);
expect((await absent.json() as FeedResponse).items).toEqual([]);
});
it("requires draft.view on all feed routes and rejects invalid cursor and ID", async () => {
expect((await request("/feeds", "GET", undefined, false)).status).toBe(401);
canView = false;
expect((await request("/feeds")).status).toBe(403);
expect((await request("/feeds/refresh", "POST")).status).toBe(403);
expect((await request(`/feeds/${[...memory.rows.keys()][0]}`)).status).toBe(403);
canView = true;
expect((await request("/feeds/not-a-uuid")).status).toBe(400);
expect((await request("/feeds?cursor=garbage")).status).toBe(400);
expect((await request("/feeds?limit=-1")).status).toBe(400);
});
});