feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile, Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model tests (31 passing), seed data for default MCP servers, and package exports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
172
src/db/prisma/schema.prisma
Normal file
172
src/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,172 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ── Users ──
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
role Role @default(USER)
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
projects Project[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
// ── MCP Servers ──
|
||||
|
||||
model McpServer {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
packageName String?
|
||||
dockerImage String?
|
||||
transport Transport @default(STDIO)
|
||||
repositoryUrl String?
|
||||
envTemplate Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
profiles McpProfile[]
|
||||
instances McpInstance[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
enum Transport {
|
||||
STDIO
|
||||
SSE
|
||||
STREAMABLE_HTTP
|
||||
}
|
||||
|
||||
// ── MCP Profiles ──
|
||||
|
||||
model McpProfile {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
serverId String
|
||||
permissions Json @default("[]")
|
||||
envOverrides Json @default("{}")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
projects ProjectMcpProfile[]
|
||||
|
||||
@@unique([name, serverId])
|
||||
@@index([serverId])
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
ownerId String
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
profiles ProjectMcpProfile[]
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
// ── Project <-> Profile join table ──
|
||||
|
||||
model ProjectMcpProfile {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
profileId String
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, profileId])
|
||||
@@index([projectId])
|
||||
@@index([profileId])
|
||||
}
|
||||
|
||||
// ── MCP Instances (running containers) ──
|
||||
|
||||
model McpInstance {
|
||||
id String @id @default(cuid())
|
||||
serverId String
|
||||
containerId String?
|
||||
status InstanceStatus @default(STOPPED)
|
||||
port Int?
|
||||
metadata Json @default("{}")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum InstanceStatus {
|
||||
STARTING
|
||||
RUNNING
|
||||
STOPPING
|
||||
STOPPED
|
||||
ERROR
|
||||
}
|
||||
|
||||
// ── Audit Logs ──
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
action String
|
||||
resource String
|
||||
resourceId String?
|
||||
details Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@index([resource])
|
||||
@@index([createdAt])
|
||||
}
|
||||
@@ -1,2 +1,18 @@
|
||||
// Database package - Prisma client and utilities
|
||||
// Will be implemented in Task 2
|
||||
export { PrismaClient } from '@prisma/client';
|
||||
export type {
|
||||
User,
|
||||
Session,
|
||||
McpServer,
|
||||
McpProfile,
|
||||
Project,
|
||||
ProjectMcpProfile,
|
||||
McpInstance,
|
||||
AuditLog,
|
||||
Role,
|
||||
Transport,
|
||||
InstanceStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
export { seedMcpServers, defaultServers } from './seed/index.js';
|
||||
export type { SeedServer } from './seed/index.js';
|
||||
|
||||
131
src/db/src/seed/index.ts
Normal file
131
src/db/src/seed/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export interface SeedServer {
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string;
|
||||
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
|
||||
repositoryUrl: string;
|
||||
envTemplate: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
isSecret: boolean;
|
||||
setupUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const defaultServers: SeedServer[] = [
|
||||
{
|
||||
name: 'slack',
|
||||
description: 'Slack MCP server for reading channels, messages, and user info',
|
||||
packageName: '@anthropic/slack-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'SLACK_BOT_TOKEN',
|
||||
description: 'Slack Bot User OAuth Token (xoxb-...)',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://api.slack.com/apps',
|
||||
},
|
||||
{
|
||||
name: 'SLACK_TEAM_ID',
|
||||
description: 'Slack Workspace Team ID',
|
||||
isSecret: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'jira',
|
||||
description: 'Jira MCP server for issues, projects, and boards',
|
||||
packageName: '@anthropic/jira-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'JIRA_URL',
|
||||
description: 'Jira instance URL (e.g., https://company.atlassian.net)',
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
name: 'JIRA_EMAIL',
|
||||
description: 'Jira account email',
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
name: 'JIRA_API_TOKEN',
|
||||
description: 'Jira API token',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
description: 'GitHub MCP server for repos, issues, PRs, and code search',
|
||||
packageName: '@anthropic/github-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'GITHUB_TOKEN',
|
||||
description: 'GitHub Personal Access Token',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://github.com/settings/tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'terraform',
|
||||
description: 'Terraform MCP server for infrastructure documentation and state',
|
||||
packageName: '@anthropic/terraform-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
|
||||
envTemplate: [],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedMcpServers(
|
||||
prisma: PrismaClient,
|
||||
servers: SeedServer[] = defaultServers,
|
||||
): Promise<number> {
|
||||
let created = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
await prisma.mcpServer.upsert({
|
||||
where: { name: server.name },
|
||||
update: {
|
||||
description: server.description,
|
||||
packageName: server.packageName,
|
||||
transport: server.transport,
|
||||
repositoryUrl: server.repositoryUrl,
|
||||
envTemplate: server.envTemplate,
|
||||
},
|
||||
create: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
packageName: server.packageName,
|
||||
transport: server.transport,
|
||||
repositoryUrl: server.repositoryUrl,
|
||||
envTemplate: server.envTemplate,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const prisma = new PrismaClient();
|
||||
seedMcpServers(prisma)
|
||||
.then((count) => {
|
||||
console.log(`Seeded ${count} MCP servers`);
|
||||
return prisma.$disconnect();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return prisma.$disconnect().then(() => process.exit(1));
|
||||
});
|
||||
}
|
||||
58
src/db/tests/helpers.ts
Normal file
58
src/db/tests/helpers.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const TEST_DATABASE_URL = process.env['DATABASE_URL'] ??
|
||||
'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test';
|
||||
|
||||
let prisma: PrismaClient | undefined;
|
||||
let schemaReady = false;
|
||||
|
||||
export function getTestClient(): PrismaClient {
|
||||
if (!prisma) {
|
||||
prisma = new PrismaClient({
|
||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||
});
|
||||
}
|
||||
return prisma;
|
||||
}
|
||||
|
||||
export async function setupTestDb(): Promise<PrismaClient> {
|
||||
const client = getTestClient();
|
||||
|
||||
// Only push schema once per process (multiple test files share the worker)
|
||||
if (!schemaReady) {
|
||||
execSync('npx prisma db push --force-reset --skip-generate', {
|
||||
cwd: new URL('..', import.meta.url).pathname,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: TEST_DATABASE_URL,
|
||||
// Consent required when Prisma detects AI agent context.
|
||||
// This targets the ephemeral test database (tmpfs-backed, port 5433).
|
||||
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
schemaReady = true;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function cleanupTestDb(): Promise<void> {
|
||||
if (prisma) {
|
||||
await prisma.$disconnect();
|
||||
prisma = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
// Delete in order respecting foreign keys
|
||||
await client.auditLog.deleteMany();
|
||||
await client.projectMcpProfile.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
await client.mcpProfile.deleteMany();
|
||||
await client.session.deleteMany();
|
||||
await client.project.deleteMany();
|
||||
await client.mcpServer.deleteMany();
|
||||
await client.user.deleteMany();
|
||||
}
|
||||
364
src/db/tests/models.test.ts
Normal file
364
src/db/tests/models.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js';
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = await setupTestDb();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
// ── Helper factories ──
|
||||
|
||||
async function createUser(overrides: { email?: string; name?: string; role?: 'USER' | 'ADMIN' } = {}) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email: overrides.email ?? `test-${Date.now()}@example.com`,
|
||||
name: overrides.name ?? 'Test User',
|
||||
role: overrides.role ?? 'USER',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
|
||||
return prisma.mcpServer.create({
|
||||
data: {
|
||||
name: overrides.name ?? `server-${Date.now()}`,
|
||||
description: 'Test server',
|
||||
packageName: '@test/mcp-server',
|
||||
transport: overrides.transport ?? 'STDIO',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── User model ──
|
||||
|
||||
describe('User', () => {
|
||||
it('creates a user with defaults', async () => {
|
||||
const user = await createUser();
|
||||
expect(user.id).toBeDefined();
|
||||
expect(user.role).toBe('USER');
|
||||
expect(user.version).toBe(1);
|
||||
expect(user.createdAt).toBeInstanceOf(Date);
|
||||
expect(user.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('enforces unique email', async () => {
|
||||
await createUser({ email: 'dup@test.com' });
|
||||
await expect(createUser({ email: 'dup@test.com' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows ADMIN role', async () => {
|
||||
const admin = await createUser({ role: 'ADMIN' });
|
||||
expect(admin.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('updates updatedAt on change', async () => {
|
||||
const user = await createUser();
|
||||
const original = user.updatedAt;
|
||||
// Small delay to ensure different timestamp
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { name: 'Updated' },
|
||||
});
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(original.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session model ──
|
||||
|
||||
describe('Session', () => {
|
||||
it('creates a session linked to user', async () => {
|
||||
const user = await createUser();
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
token: 'test-token-123',
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
},
|
||||
});
|
||||
expect(session.token).toBe('test-token-123');
|
||||
expect(session.userId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('enforces unique token', async () => {
|
||||
const user = await createUser();
|
||||
const data = {
|
||||
token: 'unique-token',
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
};
|
||||
await prisma.session.create({ data });
|
||||
await expect(prisma.session.create({ data })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades delete when user is deleted', async () => {
|
||||
const user = await createUser();
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
token: 'cascade-token',
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400_000),
|
||||
},
|
||||
});
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
const sessions = await prisma.session.findMany({ where: { userId: user.id } });
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpServer model ──
|
||||
|
||||
describe('McpServer', () => {
|
||||
it('creates a server with defaults', async () => {
|
||||
const server = await createServer();
|
||||
expect(server.transport).toBe('STDIO');
|
||||
expect(server.version).toBe(1);
|
||||
expect(server.envTemplate).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces unique name', async () => {
|
||||
await createServer({ name: 'slack' });
|
||||
await expect(createServer({ name: 'slack' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores envTemplate as JSON', async () => {
|
||||
const server = await prisma.mcpServer.create({
|
||||
data: {
|
||||
name: 'with-env',
|
||||
envTemplate: [
|
||||
{ name: 'API_KEY', description: 'Key', isSecret: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
const envTemplate = server.envTemplate as Array<{ name: string }>;
|
||||
expect(envTemplate).toHaveLength(1);
|
||||
expect(envTemplate[0].name).toBe('API_KEY');
|
||||
});
|
||||
|
||||
it('supports SSE transport', async () => {
|
||||
const server = await createServer({ transport: 'SSE' });
|
||||
expect(server.transport).toBe('SSE');
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpProfile model ──
|
||||
|
||||
describe('McpProfile', () => {
|
||||
it('creates a profile linked to server', async () => {
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: {
|
||||
name: 'readonly',
|
||||
serverId: server.id,
|
||||
permissions: ['read'],
|
||||
},
|
||||
});
|
||||
expect(profile.name).toBe('readonly');
|
||||
expect(profile.serverId).toBe(server.id);
|
||||
});
|
||||
|
||||
it('enforces unique name per server', async () => {
|
||||
const server = await createServer();
|
||||
const data = { name: 'default', serverId: server.id };
|
||||
await prisma.mcpProfile.create({ data });
|
||||
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows same profile name on different servers', async () => {
|
||||
const server1 = await createServer({ name: 'server-1' });
|
||||
const server2 = await createServer({ name: 'server-2' });
|
||||
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
|
||||
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
|
||||
expect(profile2.name).toBe('default');
|
||||
});
|
||||
|
||||
it('cascades delete when server is deleted', async () => {
|
||||
const server = await createServer();
|
||||
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
|
||||
await prisma.mcpServer.delete({ where: { id: server.id } });
|
||||
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
|
||||
expect(profiles).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Project model ──
|
||||
|
||||
describe('Project', () => {
|
||||
it('creates a project with owner', async () => {
|
||||
const user = await createUser();
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'weekly-reports', ownerId: user.id },
|
||||
});
|
||||
expect(project.name).toBe('weekly-reports');
|
||||
expect(project.ownerId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('enforces unique project name', async () => {
|
||||
const user = await createUser();
|
||||
await prisma.project.create({ data: { name: 'dup', ownerId: user.id } });
|
||||
await expect(
|
||||
prisma.project.create({ data: { name: 'dup', ownerId: user.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades delete when owner is deleted', async () => {
|
||||
const user = await createUser();
|
||||
await prisma.project.create({ data: { name: 'orphan', ownerId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
const projects = await prisma.project.findMany({ where: { ownerId: user.id } });
|
||||
expect(projects).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProjectMcpProfile (join table) ──
|
||||
|
||||
describe('ProjectMcpProfile', () => {
|
||||
it('links project to profile', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const link = await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
expect(link.projectId).toBe(project.id);
|
||||
expect(link.profileId).toBe(profile.id);
|
||||
});
|
||||
|
||||
it('enforces unique project+profile combination', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const data = { projectId: project.id, profileId: profile.id };
|
||||
await prisma.projectMcpProfile.create({ data });
|
||||
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loads profiles through project include', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'slack-ro', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'reports', ownerId: user.id },
|
||||
});
|
||||
await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
|
||||
const loaded = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { profiles: { include: { profile: true } } },
|
||||
});
|
||||
expect(loaded!.profiles).toHaveLength(1);
|
||||
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpInstance model ──
|
||||
|
||||
describe('McpInstance', () => {
|
||||
it('creates an instance linked to server', async () => {
|
||||
const server = await createServer();
|
||||
const instance = await prisma.mcpInstance.create({
|
||||
data: { serverId: server.id },
|
||||
});
|
||||
expect(instance.status).toBe('STOPPED');
|
||||
expect(instance.serverId).toBe(server.id);
|
||||
});
|
||||
|
||||
it('tracks instance status transitions', async () => {
|
||||
const server = await createServer();
|
||||
const instance = await prisma.mcpInstance.create({
|
||||
data: { serverId: server.id, status: 'STARTING' },
|
||||
});
|
||||
const running = await prisma.mcpInstance.update({
|
||||
where: { id: instance.id },
|
||||
data: { status: 'RUNNING', containerId: 'abc123', port: 8080 },
|
||||
});
|
||||
expect(running.status).toBe('RUNNING');
|
||||
expect(running.containerId).toBe('abc123');
|
||||
expect(running.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('cascades delete when server is deleted', async () => {
|
||||
const server = await createServer();
|
||||
await prisma.mcpInstance.create({ data: { serverId: server.id } });
|
||||
await prisma.mcpServer.delete({ where: { id: server.id } });
|
||||
const instances = await prisma.mcpInstance.findMany({ where: { serverId: server.id } });
|
||||
expect(instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AuditLog model ──
|
||||
|
||||
describe('AuditLog', () => {
|
||||
it('creates an audit log entry', async () => {
|
||||
const user = await createUser();
|
||||
const log = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'CREATE',
|
||||
resource: 'McpServer',
|
||||
resourceId: 'server-123',
|
||||
details: { name: 'slack' },
|
||||
},
|
||||
});
|
||||
expect(log.action).toBe('CREATE');
|
||||
expect(log.resource).toBe('McpServer');
|
||||
expect(log.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('supports querying by action and resource', async () => {
|
||||
const user = await createUser();
|
||||
await prisma.auditLog.createMany({
|
||||
data: [
|
||||
{ userId: user.id, action: 'CREATE', resource: 'McpServer' },
|
||||
{ userId: user.id, action: 'UPDATE', resource: 'McpServer' },
|
||||
{ userId: user.id, action: 'CREATE', resource: 'Project' },
|
||||
],
|
||||
});
|
||||
|
||||
const creates = await prisma.auditLog.findMany({
|
||||
where: { action: 'CREATE' },
|
||||
});
|
||||
expect(creates).toHaveLength(2);
|
||||
|
||||
const serverLogs = await prisma.auditLog.findMany({
|
||||
where: { resource: 'McpServer' },
|
||||
});
|
||||
expect(serverLogs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('cascades delete when user is deleted', async () => {
|
||||
const user = await createUser();
|
||||
await prisma.auditLog.create({
|
||||
data: { userId: user.id, action: 'TEST', resource: 'Test' },
|
||||
});
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
const logs = await prisma.auditLog.findMany({ where: { userId: user.id } });
|
||||
expect(logs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
71
src/db/tests/seed.test.ts
Normal file
71
src/db/tests/seed.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
|
||||
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = await setupTestDb();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
describe('seedMcpServers', () => {
|
||||
it('seeds all default servers', async () => {
|
||||
const count = await seedMcpServers(prisma);
|
||||
expect(count).toBe(defaultServers.length);
|
||||
|
||||
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
|
||||
expect(servers).toHaveLength(defaultServers.length);
|
||||
|
||||
const names = servers.map((s) => s.name);
|
||||
expect(names).toContain('slack');
|
||||
expect(names).toContain('github');
|
||||
expect(names).toContain('jira');
|
||||
expect(names).toContain('terraform');
|
||||
});
|
||||
|
||||
it('is idempotent (upsert)', async () => {
|
||||
await seedMcpServers(prisma);
|
||||
const count = await seedMcpServers(prisma);
|
||||
expect(count).toBe(defaultServers.length);
|
||||
|
||||
const servers = await prisma.mcpServer.findMany();
|
||||
expect(servers).toHaveLength(defaultServers.length);
|
||||
});
|
||||
|
||||
it('seeds envTemplate correctly', async () => {
|
||||
await seedMcpServers(prisma);
|
||||
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
|
||||
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
|
||||
expect(envTemplate).toHaveLength(2);
|
||||
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
|
||||
expect(envTemplate[0].isSecret).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts custom server list', async () => {
|
||||
const custom = [
|
||||
{
|
||||
name: 'custom-server',
|
||||
description: 'Custom test server',
|
||||
packageName: '@test/custom',
|
||||
transport: 'STDIO' as const,
|
||||
repositoryUrl: 'https://example.com',
|
||||
envTemplate: [],
|
||||
},
|
||||
];
|
||||
const count = await seedMcpServers(prisma, custom);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const servers = await prisma.mcpServer.findMany();
|
||||
expect(servers).toHaveLength(1);
|
||||
expect(servers[0].name).toBe('custom-server');
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,7 @@ export default defineProject({
|
||||
test: {
|
||||
name: 'db',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
// Test files share the same database — run sequentially
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user