Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
/**
|
|
* DrizzleMessageRepository Tests
|
|
*
|
|
* Tests for the Message repository adapter.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { DrizzleMessageRepository } from './message.js';
|
|
import { DrizzleAgentRepository } from './agent.js';
|
|
import { DrizzleTaskRepository } from './task.js';
|
|
import { DrizzlePhaseRepository } from './phase.js';
|
|
import { DrizzleInitiativeRepository } from './initiative.js';
|
|
import { createTestDatabase } from './test-helpers.js';
|
|
import type { DrizzleDatabase } from '../../index.js';
|
|
|
|
describe('DrizzleMessageRepository', () => {
|
|
let db: DrizzleDatabase;
|
|
let messageRepo: DrizzleMessageRepository;
|
|
let agentRepo: DrizzleAgentRepository;
|
|
let testAgentId: string;
|
|
|
|
beforeEach(async () => {
|
|
db = createTestDatabase();
|
|
messageRepo = new DrizzleMessageRepository(db);
|
|
agentRepo = new DrizzleAgentRepository(db);
|
|
|
|
// Create required hierarchy for agent FK
|
|
const taskRepo = new DrizzleTaskRepository(db);
|
|
const phaseRepo = new DrizzlePhaseRepository(db);
|
|
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
|
|
|
const initiative = await initiativeRepo.create({
|
|
name: 'Test Initiative',
|
|
});
|
|
const phase = await phaseRepo.create({
|
|
initiativeId: initiative.id,
|
|
name: 'Test Phase',
|
|
});
|
|
const task = await taskRepo.create({
|
|
phaseId: phase.id,
|
|
name: 'Test Task',
|
|
order: 1,
|
|
});
|
|
|
|
// Create a test agent
|
|
const agent = await agentRepo.create({
|
|
name: 'test-agent',
|
|
worktreeId: 'worktree-123',
|
|
taskId: task.id,
|
|
});
|
|
testAgentId = agent.id;
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create agent→user message (question)', async () => {
|
|
const message = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Do you want to proceed with deployment?',
|
|
requiresResponse: true,
|
|
});
|
|
|
|
expect(message.id).toBeDefined();
|
|
expect(message.id.length).toBeGreaterThan(0);
|
|
expect(message.senderType).toBe('agent');
|
|
expect(message.senderId).toBe(testAgentId);
|
|
expect(message.recipientType).toBe('user');
|
|
expect(message.recipientId).toBeNull();
|
|
expect(message.type).toBe('question');
|
|
expect(message.content).toBe('Do you want to proceed with deployment?');
|
|
expect(message.requiresResponse).toBe(true);
|
|
expect(message.status).toBe('pending');
|
|
expect(message.parentMessageId).toBeNull();
|
|
expect(message.createdAt).toBeInstanceOf(Date);
|
|
expect(message.updatedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should create agent→user message (notification, requiresResponse=false)', async () => {
|
|
const message = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'info',
|
|
content: 'Build completed successfully.',
|
|
requiresResponse: false,
|
|
});
|
|
|
|
expect(message.type).toBe('info');
|
|
expect(message.requiresResponse).toBe(false);
|
|
expect(message.status).toBe('pending');
|
|
});
|
|
|
|
it('should create user→agent response', async () => {
|
|
// First create the question
|
|
const question = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Which database?',
|
|
requiresResponse: true,
|
|
});
|
|
|
|
// Then create user response
|
|
const response = await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
type: 'response',
|
|
content: 'Use PostgreSQL',
|
|
parentMessageId: question.id,
|
|
});
|
|
|
|
expect(response.senderType).toBe('user');
|
|
expect(response.senderId).toBeNull();
|
|
expect(response.recipientType).toBe('agent');
|
|
expect(response.recipientId).toBe(testAgentId);
|
|
expect(response.parentMessageId).toBe(question.id);
|
|
});
|
|
|
|
it('should default type to info', async () => {
|
|
const message = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Status update',
|
|
});
|
|
|
|
expect(message.type).toBe('info');
|
|
});
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return null for non-existent message', async () => {
|
|
const result = await messageRepo.findById('non-existent-id');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should find an existing message', async () => {
|
|
const created = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Test message',
|
|
});
|
|
|
|
const found = await messageRepo.findById(created.id);
|
|
expect(found).not.toBeNull();
|
|
expect(found!.id).toBe(created.id);
|
|
expect(found!.content).toBe('Test message');
|
|
});
|
|
});
|
|
|
|
describe('findBySender', () => {
|
|
it('should find messages by agent sender', async () => {
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Message 1',
|
|
});
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Message 2',
|
|
});
|
|
|
|
const messages = await messageRepo.findBySender('agent', testAgentId);
|
|
expect(messages.length).toBe(2);
|
|
});
|
|
|
|
it('should find messages by user sender', async () => {
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
content: 'User message 1',
|
|
});
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
content: 'User message 2',
|
|
});
|
|
|
|
const messages = await messageRepo.findBySender('user');
|
|
expect(messages.length).toBe(2);
|
|
});
|
|
|
|
it('should return empty array when no messages from sender', async () => {
|
|
const messages = await messageRepo.findBySender('agent', 'nonexistent-id');
|
|
expect(messages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('findByRecipient', () => {
|
|
it('should find messages by user recipient', async () => {
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'For user 1',
|
|
});
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'For user 2',
|
|
});
|
|
|
|
const messages = await messageRepo.findByRecipient('user');
|
|
expect(messages.length).toBe(2);
|
|
});
|
|
|
|
it('should find messages by agent recipient', async () => {
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
content: 'For agent',
|
|
});
|
|
|
|
const messages = await messageRepo.findByRecipient('agent', testAgentId);
|
|
expect(messages.length).toBe(1);
|
|
});
|
|
|
|
it('should return empty array when no messages for recipient', async () => {
|
|
const messages = await messageRepo.findByRecipient('agent', 'nonexistent-id');
|
|
expect(messages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('findPendingForUser', () => {
|
|
it('should return only user-targeted pending messages', async () => {
|
|
// Create pending message for user
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Pending for user',
|
|
status: 'pending',
|
|
});
|
|
|
|
// Create read message for user (should not be returned)
|
|
const readMessage = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Already read',
|
|
});
|
|
await messageRepo.update(readMessage.id, { status: 'read' });
|
|
|
|
// Create pending message for agent (should not be returned)
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
content: 'For agent not user',
|
|
});
|
|
|
|
const pending = await messageRepo.findPendingForUser();
|
|
expect(pending.length).toBe(1);
|
|
expect(pending[0].content).toBe('Pending for user');
|
|
});
|
|
|
|
it('should return empty array when no pending messages for user', async () => {
|
|
const pending = await messageRepo.findPendingForUser();
|
|
expect(pending).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('findRequiringResponse', () => {
|
|
it('should return only messages needing response', async () => {
|
|
// Create message requiring response
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Requires answer',
|
|
requiresResponse: true,
|
|
});
|
|
|
|
// Create message not requiring response
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'info',
|
|
content: 'Just info',
|
|
requiresResponse: false,
|
|
});
|
|
|
|
// Create message that required response but was responded
|
|
const responded = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Already answered',
|
|
requiresResponse: true,
|
|
});
|
|
await messageRepo.update(responded.id, { status: 'responded' });
|
|
|
|
const requiring = await messageRepo.findRequiringResponse();
|
|
expect(requiring.length).toBe(1);
|
|
expect(requiring[0].content).toBe('Requires answer');
|
|
});
|
|
|
|
it('should return empty array when no messages require response', async () => {
|
|
const requiring = await messageRepo.findRequiringResponse();
|
|
expect(requiring).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('findReplies (message threading)', () => {
|
|
it('should find all replies to a parent message', async () => {
|
|
// Create original question
|
|
const question = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Original question',
|
|
requiresResponse: true,
|
|
});
|
|
|
|
// Create two replies
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
type: 'response',
|
|
content: 'First reply',
|
|
parentMessageId: question.id,
|
|
});
|
|
await messageRepo.create({
|
|
senderType: 'user',
|
|
recipientType: 'agent',
|
|
recipientId: testAgentId,
|
|
type: 'response',
|
|
content: 'Second reply',
|
|
parentMessageId: question.id,
|
|
});
|
|
|
|
// Create unrelated message (should not be in replies)
|
|
await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Unrelated message',
|
|
});
|
|
|
|
const replies = await messageRepo.findReplies(question.id);
|
|
expect(replies.length).toBe(2);
|
|
expect(replies.every((r) => r.parentMessageId === question.id)).toBe(true);
|
|
});
|
|
|
|
it('should return empty array when message has no replies', async () => {
|
|
const message = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'No replies',
|
|
});
|
|
|
|
const replies = await messageRepo.findReplies(message.id);
|
|
expect(replies).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('update status flow', () => {
|
|
it('should update status: pending → read → responded', async () => {
|
|
const message = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
type: 'question',
|
|
content: 'Status flow test',
|
|
requiresResponse: true,
|
|
});
|
|
expect(message.status).toBe('pending');
|
|
|
|
// Wait to ensure different timestamps
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
// Update to read
|
|
const readMessage = await messageRepo.update(message.id, { status: 'read' });
|
|
expect(readMessage.status).toBe('read');
|
|
expect(readMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(message.updatedAt.getTime());
|
|
|
|
// Wait again
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
// Update to responded
|
|
const respondedMessage = await messageRepo.update(readMessage.id, {
|
|
status: 'responded',
|
|
});
|
|
expect(respondedMessage.status).toBe('responded');
|
|
expect(respondedMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
readMessage.updatedAt.getTime()
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should throw for non-existent message', async () => {
|
|
await expect(
|
|
messageRepo.update('non-existent-id', { status: 'read' })
|
|
).rejects.toThrow('Message not found');
|
|
});
|
|
|
|
it('should update content and updatedAt', async () => {
|
|
const created = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'Original content',
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
const updated = await messageRepo.update(created.id, {
|
|
content: 'Updated content',
|
|
});
|
|
|
|
expect(updated.content).toBe('Updated content');
|
|
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete an existing message', async () => {
|
|
const created = await messageRepo.create({
|
|
senderType: 'agent',
|
|
senderId: testAgentId,
|
|
recipientType: 'user',
|
|
content: 'To delete',
|
|
});
|
|
|
|
await messageRepo.delete(created.id);
|
|
|
|
const found = await messageRepo.findById(created.id);
|
|
expect(found).toBeNull();
|
|
});
|
|
|
|
it('should throw for non-existent message', async () => {
|
|
await expect(messageRepo.delete('non-existent-id')).rejects.toThrow(
|
|
'Message not found'
|
|
);
|
|
});
|
|
});
|
|
});
|