This commit is contained in:
392
packages/server/tests/routes/messages.test.ts
Normal file
392
packages/server/tests/routes/messages.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildServer } from '../../src/index.js';
|
||||
import { users, teams, teamMembers, teamEntities, tasks, messages } from '../../src/db/schema.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
describe('Waggle Dance Messages API', () => {
|
||||
let server: Awaited<ReturnType<typeof buildServer>>;
|
||||
let ownerId: string;
|
||||
let memberId: string;
|
||||
let outsiderId: string;
|
||||
let teamSlug: string;
|
||||
let teamId: string;
|
||||
|
||||
const SLUG_PREFIX = 'msgtest-';
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await buildServer();
|
||||
|
||||
// Clean up any leftover test data (order matters for FK constraints)
|
||||
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'msgtest_%'`);
|
||||
|
||||
// Create test users
|
||||
const [owner] = await server.db.insert(users).values({
|
||||
clerkId: 'msgtest_owner',
|
||||
displayName: 'Msg Owner',
|
||||
email: 'msgtest_owner@test.com',
|
||||
}).returning();
|
||||
ownerId = owner.id;
|
||||
|
||||
const [member] = await server.db.insert(users).values({
|
||||
clerkId: 'msgtest_member',
|
||||
displayName: 'Msg Member',
|
||||
email: 'msgtest_member@test.com',
|
||||
}).returning();
|
||||
memberId = member.id;
|
||||
|
||||
const [outsider] = await server.db.insert(users).values({
|
||||
clerkId: 'msgtest_outsider',
|
||||
displayName: 'Msg Outsider',
|
||||
email: 'msgtest_outsider@test.com',
|
||||
}).returning();
|
||||
outsiderId = outsider.id;
|
||||
|
||||
// Create a team with owner + member
|
||||
const [team] = await server.db.insert(teams).values({
|
||||
name: 'Msg Test Team',
|
||||
slug: `${SLUG_PREFIX}waggle`,
|
||||
ownerId,
|
||||
}).returning();
|
||||
teamId = team.id;
|
||||
teamSlug = team.slug;
|
||||
|
||||
await server.db.insert(teamMembers).values([
|
||||
{ teamId, userId: ownerId, role: 'owner' },
|
||||
{ teamId, userId: memberId, role: 'member' },
|
||||
]);
|
||||
|
||||
// Seed some team_entities and tasks for hive-check tests
|
||||
await server.db.insert(teamEntities).values([
|
||||
{ teamId, entityType: 'concept', name: 'Machine Learning', properties: { domain: 'AI' }, sharedBy: ownerId },
|
||||
{ teamId, entityType: 'tool', name: 'TensorFlow', properties: { lang: 'python' }, sharedBy: memberId },
|
||||
{ teamId, entityType: 'concept', name: 'Database Design', properties: { domain: 'engineering' }, sharedBy: ownerId },
|
||||
]);
|
||||
|
||||
await server.db.insert(tasks).values([
|
||||
{ teamId, title: 'Implement ML pipeline', description: 'Build a machine learning pipeline', status: 'open', createdBy: ownerId },
|
||||
{ teamId, title: 'Fix login bug', description: 'Login page crashes on mobile', status: 'done', createdBy: memberId },
|
||||
]);
|
||||
|
||||
// Override auth handler
|
||||
server._authHandler.fn = async function (request, reply) {
|
||||
const testUserId = request.headers['x-test-user-id'] as string;
|
||||
if (!testUserId) {
|
||||
return reply.code(401).send({ error: 'Missing x-test-user-id header' });
|
||||
}
|
||||
request.userId = testUserId;
|
||||
request.clerkId = 'test';
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.db.execute(sql`DELETE FROM messages WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_entities WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM tasks WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_requests WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_overrides WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM team_capability_policies WHERE team_id IN (SELECT id FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'})`);
|
||||
await server.db.execute(sql`DELETE FROM teams WHERE slug LIKE ${SLUG_PREFIX + '%'}`);
|
||||
await server.db.execute(sql`DELETE FROM users WHERE clerk_id LIKE 'msgtest_%'`);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('sends a broadcast message and returns 201', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'New skill available', details: 'web-scraper v2' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.type).toBe('broadcast');
|
||||
expect(body.subtype).toBe('discovery');
|
||||
expect(body.content).toEqual({ topic: 'New skill available', details: 'web-scraper v2' });
|
||||
expect(body.senderId).toBe(ownerId);
|
||||
expect(body.teamId).toBe(teamId);
|
||||
expect(body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('sends a request message with valid type-subtype combo', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: {
|
||||
type: 'request',
|
||||
subtype: 'knowledge_check',
|
||||
content: { query: 'Who knows about React?' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.type).toBe('request');
|
||||
expect(body.subtype).toBe('knowledge_check');
|
||||
});
|
||||
|
||||
it('rejects invalid type-subtype combination with 400', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
type: 'request',
|
||||
subtype: 'discovery', // discovery is a broadcast subtype, not request
|
||||
content: { query: 'test' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.error).toContain('Invalid type-subtype combination');
|
||||
});
|
||||
|
||||
it('stores routing field when provided', async () => {
|
||||
const routing = [
|
||||
{ userId: memberId, reason: 'Expert in ML' },
|
||||
];
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'routed_share',
|
||||
content: { data: 'ML model results' },
|
||||
routing,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.routing).toEqual(routing);
|
||||
});
|
||||
|
||||
it('stores referenceId when provided', async () => {
|
||||
// First create a message to reference
|
||||
const firstRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: {
|
||||
type: 'request',
|
||||
subtype: 'task_delegation',
|
||||
content: { task: 'Review PR #42' },
|
||||
},
|
||||
});
|
||||
const firstMsg = JSON.parse(firstRes.body);
|
||||
|
||||
// Send a response referencing the first message
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
type: 'response',
|
||||
subtype: 'task_claim',
|
||||
content: { accepted: true },
|
||||
referenceId: firstMsg.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.referenceId).toBe(firstMsg.id);
|
||||
});
|
||||
|
||||
it('lists messages for the team', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('filters messages by type', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/teams/${teamSlug}/messages?type=broadcast`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.every((m: { type: string }) => m.type === 'broadcast')).toBe(true);
|
||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('filters messages by subtype', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/teams/${teamSlug}/messages?subtype=discovery`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body.every((m: { subtype: string }) => m.subtype === 'discovery')).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes message to Redis channel on send', async () => {
|
||||
// Subscribe to the team channel before sending
|
||||
const receivedMessages: string[] = [];
|
||||
await server.redisSub.subscribe(`team:${teamId}:waggle`);
|
||||
server.redisSub.on('message', (_channel: string, message: string) => {
|
||||
receivedMessages.push(message);
|
||||
});
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'skill_share',
|
||||
content: { skill: 'data-analysis', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
||||
// Give Redis a moment to deliver
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(receivedMessages.length).toBeGreaterThanOrEqual(1);
|
||||
const published = JSON.parse(receivedMessages[receivedMessages.length - 1]);
|
||||
expect(published.subtype).toBe('skill_share');
|
||||
expect(published.content).toEqual({ skill: 'data-analysis', version: '1.0' });
|
||||
|
||||
// Cleanup subscription
|
||||
await server.redisSub.unsubscribe(`team:${teamId}:waggle`);
|
||||
server.redisSub.removeAllListeners('message');
|
||||
});
|
||||
|
||||
it('non-member gets 403 when sending messages', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': outsiderId },
|
||||
payload: {
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { test: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('non-member gets 403 when listing messages', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/teams/${teamSlug}/messages`,
|
||||
headers: { 'x-test-user-id': outsiderId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
describe('hive-check', () => {
|
||||
it('finds matching entities by topic', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { topic: 'Machine Learning' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.entities.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.entities.some((e: { name: string }) => e.name === 'Machine Learning')).toBe(true);
|
||||
});
|
||||
|
||||
it('finds matching tasks by topic', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': memberId },
|
||||
payload: { topic: 'ML pipeline' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.relatedTasks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.relatedTasks.some((t: { title: string }) => t.title.includes('ML pipeline'))).toBe(true);
|
||||
});
|
||||
|
||||
it('finds matching broadcast messages', async () => {
|
||||
// We already sent broadcast messages earlier in the test suite
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { topic: 'anything' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.relatedMessages).toBeDefined();
|
||||
expect(Array.isArray(body.relatedMessages)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty results for non-matching topic', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: { topic: 'xyzzy_nonexistent_topic_12345' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = JSON.parse(response.body);
|
||||
expect(body.entities).toHaveLength(0);
|
||||
expect(body.relatedTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-member gets 403 on hive-check', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': outsiderId },
|
||||
payload: { topic: 'test' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('validates hive-check input', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: `/api/teams/${teamSlug}/messages/hive-check`,
|
||||
headers: { 'x-test-user-id': ownerId },
|
||||
payload: {}, // missing topic
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user