Files
backspace/packages/server/src/routes/social.federated.test.ts
T
Jannis Braun 0574a10ff3 feat(social): federated branch for POST /api/social/requests (happy path)
Refactors the POST handler into handleLocalFriendRequest + handleFederatedFriendRequest helpers. The federated branch resolves the target domain, ensures peering, looks up the remote user, creates/hydrates a replicated stub, and writes a transactional (friend_requests + mutation_log + outbox) event with relayMessageId set. Also exports hydrateReplicatedUserProfile from federation.ts and adds the T11 happy-path test.
2026-04-25 22:04:36 +02:00

174 lines
6.1 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CALLER_ID = 'caller-id';
let sqlite: Database.Database;
let testDb: ReturnType<typeof drizzle<typeof schema>>;
const sendToUser = vi.fn();
const ensurePeeredMock = vi.fn();
const lookupRemoteUserMock = vi.fn();
const resolveOriginFromHostnameMock = vi.fn();
vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema }));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => { req.userId = CALLER_ID; },
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: { sendToUser, sendToAdmins: vi.fn(), sendToDmMembers: vi.fn(), getAllOnlineUserIds: () => [] },
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
vi.mock('../utils/federationPeering.js', () => ({
ensurePeered: (...args: unknown[]) => ensurePeeredMock(...args),
racePeering: vi.fn(),
}));
vi.mock('../utils/federationLookup.js', () => ({
lookupRemoteUser: (...args: unknown[]) => lookupRemoteUserMock(...args),
}));
vi.mock('../utils/federationOriginResolve.js', () => ({
resolveOriginFromHostname: (...args: unknown[]) => resolveOriginFromHostnameMock(...args),
}));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedSelf(opts: { homeInstance?: string | null; homeUserId?: string | null } = {}): void {
testDb.insert(schema.users).values({
id: CALLER_ID,
username: 'caller',
displayName: 'Caller',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
homeInstance: opts.homeInstance ?? null,
homeUserId: opts.homeUserId ?? null,
createdAt: Date.now(),
}).run();
// Seed instance_settings with relay enabled so queue/log writes are not silently skipped.
// Use raw exec to avoid the updatedAt NOT NULL constraint (no default in schema).
sqlite.exec(`INSERT OR IGNORE INTO instance_settings (id, federation_relay_enabled, updated_at) VALUES (1, 1, ${Date.now()})`);
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { socialRoutes } = await import('./social.js');
await app.register(socialRoutes);
await app.ready();
return app;
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sendToUser.mockReset();
ensurePeeredMock.mockReset();
lookupRemoteUserMock.mockReset();
resolveOriginFromHostnameMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('POST /api/social/requests — federated branch (happy path)', () => {
it('creates a stub, inserts the request with relayMessageId, queues a relay event', async () => {
seedSelf();
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'peer-1' });
lookupRemoteUserMock.mockResolvedValue({
ok: true,
homeUserId: 'remote-alice',
username: 'alice',
profile: {
displayName: 'Alice',
avatar: null,
avatarColor: 'mint',
banner: null,
bio: 'hi',
},
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/social/requests',
payload: { username: 'alice@orbit.test' },
});
// Status and body
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body) as { success: boolean; requestId: string };
expect(body.success).toBe(true);
expect(typeof body.requestId).toBe('string');
// friend_requests row
const reqRow = testDb.select().from(schema.friendRequests)
.where(eq(schema.friendRequests.id, body.requestId))
.get();
expect(reqRow).toBeTruthy();
expect(reqRow!.fromId).toBe(CALLER_ID);
expect(reqRow!.relayMessageId).toMatch(/^friend_req:/);
// Stub user exists with correct federated identity
const stub = testDb.select().from(schema.users)
.where(eq(schema.users.homeUserId, 'remote-alice'))
.get();
expect(stub).toBeTruthy();
expect(stub!.homeInstance).toBe('orbit.test');
expect(stub!.displayName).toBe('Alice');
expect(stub!.bio).toBe('hi');
// federation_outbox row
const peer = testDb.select({ id: schema.federationPeers.id, origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://orbit.test'))
.get();
expect(peer).toBeTruthy();
const outboxRow = testDb.select().from(schema.federationOutbox)
.where(eq(schema.federationOutbox.peerId, peer!.id))
.get();
expect(outboxRow).toBeTruthy();
expect(outboxRow!.eventType).toBe('friend_request_create');
expect(outboxRow!.entityId).toBe(reqRow!.relayMessageId);
// federation_mutation_log row
const mutationRow = testDb.select().from(schema.federationMutationLog)
.where(eq(schema.federationMutationLog.entityId, reqRow!.relayMessageId!))
.get();
expect(mutationRow).toBeTruthy();
// WS broadcast to sender
expect(sendToUser).toHaveBeenCalledWith(
CALLER_ID,
expect.objectContaining({
type: 'friend_request_sent',
request: expect.objectContaining({ id: body.requestId }),
}),
);
});
});