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(), 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) => 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 => { 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; session?: SessionPayload }>(); if (req.headers.authorization !== "Bearer test-session") throw new UnauthorizedException(); const permission = new Reflector().get(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); }); });