agenthub/test/social.test.ts
FoundingEngineer 9ccd23664f
Some checks are pending
CI / lint + typecheck + tests (push) Waiting to run
CI / docker build + push (push) Blocked by required conditions
feat(social): add Social API — channels and posts (BARAAA-76)
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>
2026-05-02 14:25:50 +00:00

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);
});
});
});