P0 foundation for AgentHub Social: schema, CRUD routes, and tests. - Add social_channels and social_posts tables to Drizzle schema - Add Drizzle migration 0001 for new tables with indexes - Add /api/v1/social/* routes: channels CRUD, posts CRUD, global feed - Add real-time social:post socket.io event on new post - Add audit events: social-channel-created, social-post-created - Add integration tests for channels, posts, feed, pagination, auth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
8 KiB
TypeScript
262 lines
8 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { buildApp } from '../src/app.js';
|
|
import { loadConfig } from '../src/config.js';
|
|
import { pool, closePool } from '../src/db/pool.js';
|
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
import {
|
|
agents,
|
|
apiTokens,
|
|
auditEvents,
|
|
socialChannels,
|
|
socialPosts,
|
|
messages,
|
|
roomMembers,
|
|
rooms,
|
|
} from '../src/db/schema.js';
|
|
|
|
describe('Social API', () => {
|
|
let app: FastifyInstance;
|
|
let adminId: string;
|
|
let agentId: string;
|
|
|
|
beforeAll(async () => {
|
|
const config = loadConfig({
|
|
...process.env,
|
|
NODE_ENV: 'test',
|
|
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
|
|
});
|
|
app = await buildApp({ config });
|
|
await app.ready();
|
|
|
|
const db = drizzle(pool);
|
|
await db.delete(socialPosts);
|
|
await db.delete(socialChannels);
|
|
await db.delete(auditEvents);
|
|
await db.delete(messages);
|
|
await db.delete(roomMembers);
|
|
await db.delete(rooms);
|
|
await db.delete(apiTokens);
|
|
await db.delete(agents);
|
|
|
|
// Create admin agent
|
|
const adminRes = await request(app.server)
|
|
.post('/api/v1/agents')
|
|
.send({ name: 'social-admin', displayName: 'Social Admin', role: 'admin' });
|
|
adminId = adminRes.body.id;
|
|
|
|
// Create regular agent
|
|
const agentRes = await request(app.server)
|
|
.post('/api/v1/agents')
|
|
.send({ name: 'social-agent', displayName: 'Social Agent', role: 'agent' });
|
|
agentId = agentRes.body.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await closePool();
|
|
});
|
|
|
|
describe('Channels', () => {
|
|
let channelId: string;
|
|
|
|
it('should create a channel (admin)', async () => {
|
|
const res = await request(app.server)
|
|
.post('/api/v1/social/channels')
|
|
.set('x-agent-id', adminId)
|
|
.send({ slug: 'general', name: 'General', description: 'Main channel' })
|
|
.expect(201);
|
|
|
|
expect(res.body).toHaveProperty('id');
|
|
expect(res.body.slug).toBe('general');
|
|
expect(res.body.name).toBe('General');
|
|
expect(res.body.description).toBe('Main channel');
|
|
channelId = res.body.id;
|
|
});
|
|
|
|
it('should reject channel creation by non-admin', async () => {
|
|
await request(app.server)
|
|
.post('/api/v1/social/channels')
|
|
.set('x-agent-id', agentId)
|
|
.send({ slug: 'hacker', name: 'Hacker' })
|
|
.expect(403);
|
|
});
|
|
|
|
it('should reject duplicate slug', async () => {
|
|
await request(app.server)
|
|
.post('/api/v1/social/channels')
|
|
.set('x-agent-id', adminId)
|
|
.send({ slug: 'general', name: 'General 2' })
|
|
.expect(409);
|
|
});
|
|
|
|
it('should list channels', async () => {
|
|
const res = await request(app.server)
|
|
.get('/api/v1/social/channels')
|
|
.set('x-agent-id', adminId)
|
|
.expect(200);
|
|
|
|
expect(res.body.channels).toHaveLength(1);
|
|
expect(res.body.channels[0].slug).toBe('general');
|
|
});
|
|
|
|
it('should get channel by id with post count', async () => {
|
|
const res = await request(app.server)
|
|
.get(`/api/v1/social/channels/${channelId}`)
|
|
.set('x-agent-id', adminId)
|
|
.expect(200);
|
|
|
|
expect(res.body.slug).toBe('general');
|
|
expect(res.body.postCount).toBe(0);
|
|
});
|
|
|
|
it('should 404 for unknown channel', async () => {
|
|
await request(app.server)
|
|
.get('/api/v1/social/channels/00000000-0000-0000-0000-000000000000')
|
|
.set('x-agent-id', adminId)
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
describe('Posts', () => {
|
|
let channelId: string;
|
|
let postId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Create a channel for post tests
|
|
const res = await request(app.server)
|
|
.post('/api/v1/social/channels')
|
|
.set('x-agent-id', adminId)
|
|
.send({ slug: 'philosophy', name: 'Philosophy' });
|
|
channelId = res.body.id;
|
|
});
|
|
|
|
it('should create a post (any agent)', async () => {
|
|
const res = await request(app.server)
|
|
.post(`/api/v1/social/channels/${channelId}/posts`)
|
|
.set('x-agent-id', agentId)
|
|
.send({ body: 'I think, therefore I am.' })
|
|
.expect(201);
|
|
|
|
expect(res.body).toHaveProperty('id');
|
|
expect(res.body.body).toBe('I think, therefore I am.');
|
|
expect(res.body.authorAgentId).toBe(agentId);
|
|
expect(res.body.authorName).toBe('Social Agent');
|
|
expect(res.body.channelSlug).toBe('philosophy');
|
|
postId = res.body.id;
|
|
});
|
|
|
|
it('should 404 when posting to unknown channel', async () => {
|
|
await request(app.server)
|
|
.post('/api/v1/social/channels/00000000-0000-0000-0000-000000000000/posts')
|
|
.set('x-agent-id', agentId)
|
|
.send({ body: 'Hello void' })
|
|
.expect(404);
|
|
});
|
|
|
|
it('should reject empty body', async () => {
|
|
await request(app.server)
|
|
.post(`/api/v1/social/channels/${channelId}/posts`)
|
|
.set('x-agent-id', agentId)
|
|
.send({ body: '' })
|
|
.expect(400);
|
|
});
|
|
|
|
it('should get a post by id', async () => {
|
|
const res = await request(app.server)
|
|
.get(`/api/v1/social/posts/${postId}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res.body.id).toBe(postId);
|
|
expect(res.body.body).toBe('I think, therefore I am.');
|
|
expect(res.body.channelSlug).toBe('philosophy');
|
|
});
|
|
|
|
it('should list posts in channel', async () => {
|
|
// Add another post
|
|
await request(app.server)
|
|
.post(`/api/v1/social/channels/${channelId}/posts`)
|
|
.set('x-agent-id', adminId)
|
|
.send({ body: 'To be or not to be.' });
|
|
|
|
const res = await request(app.server)
|
|
.get(`/api/v1/social/channels/${channelId}/posts`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res.body.posts.length).toBe(2);
|
|
expect(res.body.channel.slug).toBe('philosophy');
|
|
// Newest first
|
|
expect(res.body.posts[0].body).toBe('To be or not to be.');
|
|
});
|
|
|
|
it('should show post count on channel', async () => {
|
|
const res = await request(app.server)
|
|
.get(`/api/v1/social/channels/${channelId}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res.body.postCount).toBe(2);
|
|
});
|
|
|
|
it('should delete own post', async () => {
|
|
await request(app.server)
|
|
.delete(`/api/v1/social/posts/${postId}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(204);
|
|
|
|
await request(app.server)
|
|
.get(`/api/v1/social/posts/${postId}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(404);
|
|
});
|
|
|
|
it('should not delete another agents post (non-admin)', async () => {
|
|
// Create post as admin
|
|
const res = await request(app.server)
|
|
.post(`/api/v1/social/channels/${channelId}/posts`)
|
|
.set('x-agent-id', adminId)
|
|
.send({ body: 'Admin thought.' });
|
|
|
|
await request(app.server)
|
|
.delete(`/api/v1/social/posts/${res.body.id}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(403);
|
|
});
|
|
});
|
|
|
|
describe('Feed', () => {
|
|
it('should return global feed across channels', async () => {
|
|
const res = await request(app.server)
|
|
.get('/api/v1/social/feed')
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res.body.posts.length).toBeGreaterThan(0);
|
|
expect(res.body.posts[0]).toHaveProperty('channelSlug');
|
|
expect(res.body.posts[0]).toHaveProperty('authorName');
|
|
});
|
|
|
|
it('should paginate feed with cursor', async () => {
|
|
const res = await request(app.server)
|
|
.get('/api/v1/social/feed?limit=1')
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res.body.posts).toHaveLength(1);
|
|
expect(res.body.hasMore).toBe(true);
|
|
expect(res.body.cursor).toBeTruthy();
|
|
|
|
// Fetch next page
|
|
const res2 = await request(app.server)
|
|
.get(`/api/v1/social/feed?limit=1&before=${res.body.cursor}`)
|
|
.set('x-agent-id', agentId)
|
|
.expect(200);
|
|
|
|
expect(res2.body.posts).toHaveLength(1);
|
|
expect(res2.body.posts[0].id).not.toBe(res.body.posts[0].id);
|
|
});
|
|
});
|
|
});
|