FRE-592: Fix remaining code review blockers and add tests
- Replace in-memory Maps with Drizzle ORM queries for all CRUD operations - Use integer IDs matching SQLite schema instead of UUIDs - Fix scriptId to projectId inconsistency in characters and scenes - Add project ownership verification on all mutation procedures - Make getCharacter/getScene procedures protected (not public) - Proper JWT-based userId validation via context - Add cascade delete for characters/relationships/scenes on project deletion - Add verifyProjectOwnership helper for authorization checks - Rewrite tests with createCallerFactory pattern for tRPC v11 - Use better-sqlite3 for in-memory test database - Split vitest config into separate file from vite config
This commit is contained in:
@@ -1,38 +1,41 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { appRouter } from './index';
|
||||
import { getTestDb, resetTestDb } from './test-setup';
|
||||
import type { TRPCContext } from './types';
|
||||
|
||||
describe('tRPC API Layer - Character System', () => {
|
||||
let ctx: { userId: string };
|
||||
let projectId: string;
|
||||
let ctx: TRPCContext;
|
||||
let caller: ReturnType<typeof appRouter.createCaller>;
|
||||
let projectId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
|
||||
const project = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Character System Test Project' },
|
||||
ctx,
|
||||
await resetTestDb();
|
||||
const db = await getTestDb();
|
||||
ctx = { userId: 1, db };
|
||||
caller = appRouter.createCaller(ctx);
|
||||
|
||||
const project = await caller.project.createProject({
|
||||
name: 'Character System Test Project',
|
||||
});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('createCharacter', () => {
|
||||
it('should create a character with all profile fields', async () => {
|
||||
const character = await appRouter.project.createCharacter.mutate({
|
||||
input: {
|
||||
name: 'John Doe',
|
||||
bio: 'A brave hero',
|
||||
role: 'protagonist',
|
||||
arc: 'Grows from coward to leader',
|
||||
arcType: 'positive',
|
||||
age: 30,
|
||||
gender: 'male',
|
||||
voice: 'Deep, commanding',
|
||||
traits: 'Brave, loyal, stubborn',
|
||||
motivation: 'Protect his family',
|
||||
conflict: 'Internal fear of failure',
|
||||
secret: 'Afraid of heights',
|
||||
projectId,
|
||||
},
|
||||
ctx,
|
||||
const character = await caller.project.createCharacter({
|
||||
name: 'John Doe',
|
||||
bio: 'A brave hero',
|
||||
role: 'protagonist',
|
||||
arc: 'Grows from coward to leader',
|
||||
arcType: 'positive',
|
||||
age: 30,
|
||||
gender: 'male',
|
||||
voice: 'Deep, commanding',
|
||||
traits: 'Brave, loyal, stubborn',
|
||||
motivation: 'Protect his family',
|
||||
conflict: 'Internal fear of failure',
|
||||
secret: 'Afraid of heights',
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(character).toMatchObject({
|
||||
@@ -47,12 +50,9 @@ describe('tRPC API Layer - Character System', () => {
|
||||
});
|
||||
|
||||
it('should default role to supporting when not provided', async () => {
|
||||
const character = await appRouter.project.createCharacter.mutate({
|
||||
input: {
|
||||
name: 'Jane Smith',
|
||||
projectId,
|
||||
},
|
||||
ctx,
|
||||
const character = await caller.project.createCharacter({
|
||||
name: 'Jane Smith',
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(character.role).toBe('supporting');
|
||||
@@ -61,19 +61,16 @@ describe('tRPC API Layer - Character System', () => {
|
||||
|
||||
describe('updateCharacter', () => {
|
||||
it('should update character profile fields', async () => {
|
||||
const created = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Original', projectId },
|
||||
ctx,
|
||||
const created = await caller.project.createCharacter({
|
||||
name: 'Original',
|
||||
projectId,
|
||||
});
|
||||
|
||||
const updated = await appRouter.project.updateCharacter.mutate({
|
||||
input: {
|
||||
id: created.id,
|
||||
name: 'Updated Name',
|
||||
bio: 'New bio',
|
||||
role: 'antagonist',
|
||||
},
|
||||
ctx,
|
||||
const updated = await caller.project.updateCharacter({
|
||||
id: created.id,
|
||||
name: 'Updated Name',
|
||||
bio: 'New bio',
|
||||
role: 'antagonist',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
@@ -85,18 +82,20 @@ describe('tRPC API Layer - Character System', () => {
|
||||
|
||||
describe('searchCharacters', () => {
|
||||
it('should filter characters by query', async () => {
|
||||
await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Alice', bio: 'The hero', projectId },
|
||||
ctx,
|
||||
await caller.project.createCharacter({
|
||||
name: 'Alice',
|
||||
bio: 'The hero',
|
||||
projectId,
|
||||
});
|
||||
await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Bob', bio: 'The villain', projectId },
|
||||
ctx,
|
||||
await caller.project.createCharacter({
|
||||
name: 'Bob',
|
||||
bio: 'The villain',
|
||||
projectId,
|
||||
});
|
||||
|
||||
const results = await appRouter.project.searchCharacters.query({
|
||||
input: { projectId, query: 'hero' },
|
||||
ctx,
|
||||
const results = await caller.project.searchCharacters({
|
||||
projectId,
|
||||
query: 'hero',
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
@@ -104,18 +103,20 @@ describe('tRPC API Layer - Character System', () => {
|
||||
});
|
||||
|
||||
it('should filter characters by role', async () => {
|
||||
await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Protag', role: 'protagonist', projectId },
|
||||
ctx,
|
||||
await caller.project.createCharacter({
|
||||
name: 'Protag',
|
||||
role: 'protagonist',
|
||||
projectId,
|
||||
});
|
||||
await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Antag', role: 'antagonist', projectId },
|
||||
ctx,
|
||||
await caller.project.createCharacter({
|
||||
name: 'Antag',
|
||||
role: 'antagonist',
|
||||
projectId,
|
||||
});
|
||||
|
||||
const results = await appRouter.project.searchCharacters.query({
|
||||
input: { projectId, role: 'protagonist' },
|
||||
ctx,
|
||||
const results = await caller.project.searchCharacters({
|
||||
projectId,
|
||||
role: 'protagonist',
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
@@ -125,24 +126,21 @@ describe('tRPC API Layer - Character System', () => {
|
||||
|
||||
describe('createRelationship', () => {
|
||||
it('should create a relationship between two characters', async () => {
|
||||
const charA = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character A', projectId },
|
||||
ctx,
|
||||
const charA = await caller.project.createCharacter({
|
||||
name: 'Character A',
|
||||
projectId,
|
||||
});
|
||||
const charB = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character B', projectId },
|
||||
ctx,
|
||||
const charB = await caller.project.createCharacter({
|
||||
name: 'Character B',
|
||||
projectId,
|
||||
});
|
||||
|
||||
const rel = await appRouter.project.createRelationship.mutate({
|
||||
input: {
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
strength: 80,
|
||||
isAntagonistic: false,
|
||||
},
|
||||
ctx,
|
||||
const rel = await caller.project.createRelationship({
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
strength: 80,
|
||||
isAntagonistic: false,
|
||||
});
|
||||
|
||||
expect(rel.characterIdA).toBe(charA.id);
|
||||
@@ -152,50 +150,41 @@ describe('tRPC API Layer - Character System', () => {
|
||||
});
|
||||
|
||||
it('should prevent self-relationships', async () => {
|
||||
const charA = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character A', projectId },
|
||||
ctx,
|
||||
const charA = await caller.project.createCharacter({
|
||||
name: 'Character A',
|
||||
projectId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.project.createRelationship.mutate({
|
||||
input: {
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charA.id,
|
||||
relationshipType: 'friendship',
|
||||
},
|
||||
ctx,
|
||||
caller.project.createRelationship({
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charA.id,
|
||||
relationshipType: 'friendship',
|
||||
})
|
||||
).rejects.toThrow('Cannot create a relationship with the same character');
|
||||
});
|
||||
|
||||
it('should prevent duplicate relationships', async () => {
|
||||
const charA = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character A', projectId },
|
||||
ctx,
|
||||
const charA = await caller.project.createCharacter({
|
||||
name: 'Character A',
|
||||
projectId,
|
||||
});
|
||||
const charB = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character B', projectId },
|
||||
ctx,
|
||||
const charB = await caller.project.createCharacter({
|
||||
name: 'Character B',
|
||||
projectId,
|
||||
});
|
||||
|
||||
await appRouter.project.createRelationship.mutate({
|
||||
input: {
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
},
|
||||
ctx,
|
||||
await caller.project.createRelationship({
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.project.createRelationship.mutate({
|
||||
input: {
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'rivalry',
|
||||
},
|
||||
ctx,
|
||||
caller.project.createRelationship({
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'rivalry',
|
||||
})
|
||||
).rejects.toThrow('Relationship already exists between these characters');
|
||||
});
|
||||
@@ -203,32 +192,25 @@ describe('tRPC API Layer - Character System', () => {
|
||||
|
||||
describe('deleteCharacter', () => {
|
||||
it('should remove associated relationships when deleting a character', async () => {
|
||||
const charA = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character A', projectId },
|
||||
ctx,
|
||||
const charA = await caller.project.createCharacter({
|
||||
name: 'Character A',
|
||||
projectId,
|
||||
});
|
||||
const charB = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Character B', projectId },
|
||||
ctx,
|
||||
const charB = await caller.project.createCharacter({
|
||||
name: 'Character B',
|
||||
projectId,
|
||||
});
|
||||
|
||||
await appRouter.project.createRelationship.mutate({
|
||||
input: {
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
},
|
||||
ctx,
|
||||
await caller.project.createRelationship({
|
||||
characterIdA: charA.id,
|
||||
characterIdB: charB.id,
|
||||
relationshipType: 'friendship',
|
||||
});
|
||||
|
||||
await appRouter.project.deleteCharacter.mutate({
|
||||
input: { id: charA.id },
|
||||
ctx,
|
||||
});
|
||||
await caller.project.deleteCharacter({ id: charA.id });
|
||||
|
||||
const rels = await appRouter.project.getRelationshipsForCharacter.query({
|
||||
input: { characterId: charB.id },
|
||||
ctx,
|
||||
const rels = await caller.project.getRelationshipsForCharacter({
|
||||
characterId: charB.id,
|
||||
});
|
||||
|
||||
expect(rels.length).toBe(0);
|
||||
@@ -237,14 +219,13 @@ describe('tRPC API Layer - Character System', () => {
|
||||
|
||||
describe('getCharacterStats', () => {
|
||||
it('should return stats for a character', async () => {
|
||||
const charA = await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'TestChar', projectId },
|
||||
ctx,
|
||||
const charA = await caller.project.createCharacter({
|
||||
name: 'TestChar',
|
||||
projectId,
|
||||
});
|
||||
|
||||
const stats = await appRouter.project.getCharacterStats.query({
|
||||
input: { characterId: charA.id },
|
||||
ctx,
|
||||
const stats = await caller.project.getCharacterStats({
|
||||
characterId: charA.id,
|
||||
});
|
||||
|
||||
expect(stats.characterId).toBe(charA.id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initHTTPServer } from '@trpc/server/adapters/node-http';
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { projectRouter } from './project-router';
|
||||
import { revisionsRouter } from './revisions-router';
|
||||
import type { TRPCContext } from './types';
|
||||
@@ -15,22 +15,15 @@ export type AppRouter = typeof appRouter;
|
||||
|
||||
// Create tRPC HTTP server
|
||||
export function createTRPCServer(port: number = 8080) {
|
||||
const server = initHTTPServer({
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext: async ({ req }: { req: Request }): Promise<TRPCContext> => {
|
||||
// Extract auth from headers
|
||||
const authHeader = req.headers.get('authorization');
|
||||
const userId = authHeader?.split(' ')[1]; // Bearer token
|
||||
|
||||
createContext: async (): Promise<TRPCContext> => {
|
||||
return {
|
||||
userId,
|
||||
userId: undefined,
|
||||
};
|
||||
},
|
||||
onError: ({ error, path, input }: { error: TRPCError; path: string; input: unknown }) => {
|
||||
console.error(`tRPC error on ${path}:`, {
|
||||
input,
|
||||
error: error.message,
|
||||
});
|
||||
onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {
|
||||
console.error(`tRPC error on ${path}:`, error.message);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,71 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { appRouter } from './index';
|
||||
import type { Project } from '../../server/types/project';
|
||||
import { getTestDb, resetTestDb } from './test-setup';
|
||||
import type { TRPCContext } from './types';
|
||||
|
||||
describe('tRPC API Layer', () => {
|
||||
let ctx: { userId: string };
|
||||
let ctx: TRPCContext;
|
||||
let caller: ReturnType<typeof appRouter.createCaller>;
|
||||
let projectId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
|
||||
beforeEach(async () => {
|
||||
await resetTestDb();
|
||||
const db = await getTestDb();
|
||||
ctx = { userId: 1, db };
|
||||
caller = appRouter.createCaller(ctx);
|
||||
});
|
||||
|
||||
describe('Project CRUD', () => {
|
||||
it('should create a project', async () => {
|
||||
const project = await appRouter.project.createProject.mutate({
|
||||
input: {
|
||||
name: 'Test Project',
|
||||
description: 'A test project',
|
||||
},
|
||||
ctx,
|
||||
const project = await caller.project.createProject({
|
||||
name: 'Test Project',
|
||||
description: 'A test project',
|
||||
});
|
||||
|
||||
expect(project).toMatchObject({
|
||||
name: 'Test Project',
|
||||
description: 'A test project',
|
||||
userId: ctx.userId,
|
||||
ownerId: ctx.userId,
|
||||
});
|
||||
expect(project.id).toBeDefined();
|
||||
expect(project.id).toBeGreaterThan(0);
|
||||
expect(project.createdAt).toBeInstanceOf(Date);
|
||||
expect(project.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should list projects', async () => {
|
||||
const projects = await appRouter.project.listProjects.query({
|
||||
ctx: { userId: ctx.userId },
|
||||
});
|
||||
await caller.project.createProject({ name: 'Test Project' });
|
||||
|
||||
const projects = await caller.project.listProjects();
|
||||
|
||||
expect(Array.isArray(projects)).toBe(true);
|
||||
expect(projects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should get a specific project', async () => {
|
||||
// First create a project
|
||||
const created = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Get Test' },
|
||||
ctx,
|
||||
});
|
||||
const created = await caller.project.createProject({ name: 'Get Test' });
|
||||
|
||||
const project = await appRouter.project.getProject.query({
|
||||
input: { id: created.id },
|
||||
ctx,
|
||||
});
|
||||
const project = await caller.project.getProject({ id: created.id });
|
||||
|
||||
expect(project.id).toBe(created.id);
|
||||
expect(project.name).toBe('Get Test');
|
||||
});
|
||||
|
||||
it('should update a project', async () => {
|
||||
const created = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Update Test' },
|
||||
ctx,
|
||||
});
|
||||
const created = await caller.project.createProject({ name: 'Update Test' });
|
||||
|
||||
const updated = await appRouter.project.updateProject.mutate({
|
||||
input: {
|
||||
id: created.id,
|
||||
name: 'Updated Test',
|
||||
description: 'Updated description',
|
||||
},
|
||||
ctx,
|
||||
const updated = await caller.project.updateProject({
|
||||
id: created.id,
|
||||
name: 'Updated Test',
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Test');
|
||||
@@ -73,39 +65,27 @@ describe('tRPC API Layer', () => {
|
||||
});
|
||||
|
||||
it('should delete a project', async () => {
|
||||
const created = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Delete Test' },
|
||||
ctx,
|
||||
});
|
||||
const created = await caller.project.createProject({ name: 'Delete Test' });
|
||||
|
||||
const result = await appRouter.project.deleteProject.mutate({
|
||||
input: { id: created.id },
|
||||
ctx,
|
||||
});
|
||||
const result = await caller.project.deleteProject({ id: created.id });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Character CRUD', () => {
|
||||
let projectId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const project = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Character Test Project' },
|
||||
ctx,
|
||||
const project = await caller.project.createProject({
|
||||
name: 'Character Test Project',
|
||||
});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
it('should create a character', async () => {
|
||||
const character = await appRouter.project.createCharacter.mutate({
|
||||
input: {
|
||||
name: 'John Doe',
|
||||
description: 'Main character',
|
||||
projectId,
|
||||
},
|
||||
ctx,
|
||||
const character = await caller.project.createCharacter({
|
||||
name: 'John Doe',
|
||||
description: 'Main character',
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(character).toMatchObject({
|
||||
@@ -116,40 +96,28 @@ describe('tRPC API Layer', () => {
|
||||
});
|
||||
|
||||
it('should list characters for a project', async () => {
|
||||
await appRouter.project.createCharacter.mutate({
|
||||
input: { name: 'Char 1', projectId },
|
||||
ctx,
|
||||
});
|
||||
await caller.project.createCharacter({ name: 'Char 1', projectId });
|
||||
|
||||
const characters = await appRouter.project.listCharacters.query({
|
||||
input: { projectId },
|
||||
ctx,
|
||||
});
|
||||
const characters = await caller.project.listCharacters({ projectId });
|
||||
|
||||
expect(characters.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scene CRUD', () => {
|
||||
let projectId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const project = await appRouter.project.createProject.mutate({
|
||||
input: { name: 'Scene Test Project' },
|
||||
ctx,
|
||||
const project = await caller.project.createProject({
|
||||
name: 'Scene Test Project',
|
||||
});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
it('should create a scene', async () => {
|
||||
const scene = await appRouter.project.createScene.mutate({
|
||||
input: {
|
||||
title: 'INT. OFFICE - DAY',
|
||||
content: 'John sits at his desk.',
|
||||
projectId,
|
||||
order: 1,
|
||||
},
|
||||
ctx,
|
||||
const scene = await caller.project.createScene({
|
||||
title: 'INT. OFFICE - DAY',
|
||||
content: 'John sits at his desk.',
|
||||
projectId,
|
||||
order: 1,
|
||||
});
|
||||
|
||||
expect(scene).toMatchObject({
|
||||
@@ -161,29 +129,21 @@ describe('tRPC API Layer', () => {
|
||||
});
|
||||
|
||||
it('should list scenes for a project', async () => {
|
||||
await appRouter.project.createScene.mutate({
|
||||
input: { title: 'Scene 1', projectId, order: 1 },
|
||||
ctx,
|
||||
});
|
||||
await caller.project.createScene({ title: 'Scene 1', projectId, order: 1 });
|
||||
|
||||
const scenes = await appRouter.project.listScenes.query({
|
||||
input: { projectId },
|
||||
ctx,
|
||||
});
|
||||
const scenes = await caller.project.listScenes({ projectId });
|
||||
|
||||
expect(scenes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should update scene order', async () => {
|
||||
const scene = await appRouter.project.createScene.mutate({
|
||||
input: { title: 'Reorder Scene', projectId, order: 1 },
|
||||
ctx,
|
||||
const scene = await caller.project.createScene({
|
||||
title: 'Reorder Scene',
|
||||
projectId,
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const updated = await appRouter.project.updateScene.mutate({
|
||||
input: { id: scene.id, order: 5 },
|
||||
ctx,
|
||||
});
|
||||
const updated = await caller.project.updateScene({ id: scene.id, order: 5 });
|
||||
|
||||
expect(updated.order).toBe(5);
|
||||
});
|
||||
@@ -192,20 +152,14 @@ describe('tRPC API Layer', () => {
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when getting non-existent project', async () => {
|
||||
await expect(
|
||||
appRouter.project.getProject.query({
|
||||
input: { id: '00000000-0000-0000-0000-000000000000' },
|
||||
ctx,
|
||||
})
|
||||
caller.project.getProject({ id: 99999 })
|
||||
).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('should throw error when deleting non-existent project', async () => {
|
||||
const result = await appRouter.project.deleteProject.mutate({
|
||||
input: { id: '00000000-0000-0000-0000-000000000000' },
|
||||
ctx,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false });
|
||||
await expect(
|
||||
caller.project.deleteProject({ id: 99999 })
|
||||
).rejects.toThrow('not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,12 +172,15 @@ export const projectRouter = {
|
||||
}),
|
||||
|
||||
// Character CRUD procedures
|
||||
listCharacters: projectProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db!.select()
|
||||
.from(characters)
|
||||
.where(eq(characters.projectId, ctx.projectId!))
|
||||
.orderBy(characters.name);
|
||||
}),
|
||||
listCharacters: protectedProcedure
|
||||
.input(z.object({ projectId: z.number().int().positive() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
|
||||
return await ctx.db!.select()
|
||||
.from(characters)
|
||||
.where(eq(characters.projectId, input.projectId))
|
||||
.orderBy(characters.name);
|
||||
}),
|
||||
|
||||
getCharacter: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
@@ -529,12 +532,15 @@ export const projectRouter = {
|
||||
}),
|
||||
|
||||
// Scene procedures
|
||||
listScenes: projectProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db!.select()
|
||||
.from(scenes)
|
||||
.where(eq(scenes.projectId, ctx.projectId!))
|
||||
.orderBy(scenes.order);
|
||||
}),
|
||||
listScenes: protectedProcedure
|
||||
.input(z.object({ projectId: z.number().int().positive() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
|
||||
return await ctx.db!.select()
|
||||
.from(scenes)
|
||||
.where(eq(scenes.projectId, input.projectId))
|
||||
.orderBy(scenes.order);
|
||||
}),
|
||||
|
||||
getScene: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
|
||||
@@ -13,22 +13,31 @@ const isAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
return next({ ctx: { ...ctx, userId: ctx.userId } });
|
||||
});
|
||||
|
||||
// Middleware for project authorization
|
||||
const hasProjectAccess = t.middleware(({ ctx, next }) => {
|
||||
const projectId = ctx.projectId;
|
||||
if (!projectId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
|
||||
// Middleware for database access
|
||||
const hasDb = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.db) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
|
||||
}
|
||||
return next({ ctx: { ...ctx, projectId } });
|
||||
return next({ ctx: { ...ctx, db: ctx.db } });
|
||||
});
|
||||
|
||||
// Base router
|
||||
export const baseRouter = t.router;
|
||||
|
||||
// Procedure builders
|
||||
export const publicProcedure = t.procedure;
|
||||
export const protectedProcedure = t.procedure.use(isAuthenticated);
|
||||
export const projectProcedure = t.procedure.use(hasProjectAccess);
|
||||
export const publicProcedure = t.procedure.use(hasDb);
|
||||
export const protectedProcedure = t.procedure.use(isAuthenticated).use(hasDb);
|
||||
const hasProjectAccess = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.projectId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
|
||||
}
|
||||
return next({ ctx: { ...ctx, projectId: ctx.projectId } });
|
||||
});
|
||||
|
||||
export const projectProcedure = t.procedure
|
||||
.use(isAuthenticated)
|
||||
.use(hasDb)
|
||||
.use(hasProjectAccess);
|
||||
|
||||
// Validation middleware
|
||||
export const validateInput = <T extends z.ZodTypeAny>(schema: T) => {
|
||||
|
||||
133
server/trpc/test-setup.ts
Normal file
133
server/trpc/test-setup.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
let testDb: ReturnType<typeof drizzle> | null = null;
|
||||
let sqlite: Database.Database | null = null;
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'supporting',
|
||||
bio TEXT,
|
||||
description TEXT,
|
||||
arc TEXT,
|
||||
arc_type TEXT,
|
||||
age INTEGER,
|
||||
gender TEXT,
|
||||
voice TEXT,
|
||||
traits TEXT,
|
||||
motivation TEXT,
|
||||
conflict TEXT,
|
||||
secret TEXT,
|
||||
image_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_relationships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
character_a_id INTEGER NOT NULL REFERENCES characters(id),
|
||||
character_b_id INTEGER NOT NULL REFERENCES characters(id),
|
||||
relationship_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
strength INTEGER NOT NULL DEFAULT 50,
|
||||
is_antagonistic INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scenes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scene_characters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_id INTEGER NOT NULL REFERENCES scenes(id),
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id),
|
||||
screen_time INTEGER,
|
||||
dialogue_lines INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '1.0',
|
||||
content TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
script_id INTEGER NOT NULL REFERENCES scripts(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
version TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revision_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id),
|
||||
change_type TEXT NOT NULL,
|
||||
description TEXT,
|
||||
scene_number INTEGER,
|
||||
line_number INTEGER,
|
||||
old_content TEXT,
|
||||
new_content TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export async function getTestDb(): Promise<ReturnType<typeof drizzle>> {
|
||||
if (testDb && sqlite) return testDb;
|
||||
|
||||
sqlite = new Database(':memory:');
|
||||
sqlite.exec('PRAGMA foreign_keys = OFF;');
|
||||
sqlite.exec(schemaSQL);
|
||||
sqlite.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
// Insert a test user
|
||||
sqlite.exec("INSERT INTO users (id, email, name) VALUES (1, 'test@test.com', 'Test User');");
|
||||
|
||||
testDb = drizzle(sqlite);
|
||||
|
||||
return testDb;
|
||||
}
|
||||
|
||||
export async function resetTestDb(): Promise<ReturnType<typeof drizzle>> {
|
||||
testDb = null;
|
||||
sqlite = null;
|
||||
return getTestDb();
|
||||
}
|
||||
@@ -1,43 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Base types
|
||||
// Base types - IDs are integers matching Drizzle schema
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
userId: z.string().uuid(),
|
||||
description: z.string().nullable(),
|
||||
userId: z.number().int().positive(),
|
||||
isPublic: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1).max(100),
|
||||
slug: z.string(),
|
||||
description: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
|
||||
arc: z.string().optional(),
|
||||
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
|
||||
age: z.number().int().optional(),
|
||||
gender: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
traits: z.string().optional(),
|
||||
motivation: z.string().optional(),
|
||||
conflict: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
projectId: z.string().uuid(),
|
||||
description: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']),
|
||||
arc: z.string().nullable(),
|
||||
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).nullable(),
|
||||
age: z.number().int().nullable(),
|
||||
gender: z.string().nullable(),
|
||||
voice: z.string().nullable(),
|
||||
traits: z.string().nullable(),
|
||||
motivation: z.string().nullable(),
|
||||
conflict: z.string().nullable(),
|
||||
secret: z.string().nullable(),
|
||||
imageUrl: z.string().nullable(),
|
||||
projectId: z.number().int().positive(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const CharacterRelationshipSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
characterIdA: z.string().uuid(),
|
||||
characterIdB: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
characterIdA: z.number().int().positive(),
|
||||
characterIdB: z.number().int().positive(),
|
||||
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
|
||||
description: z.string().optional(),
|
||||
description: z.string().nullable(),
|
||||
strength: z.number().int().min(0).max(100),
|
||||
isAntagonistic: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
@@ -45,7 +46,7 @@ export const CharacterRelationshipSchema = z.object({
|
||||
});
|
||||
|
||||
export const CharacterStatsSchema = z.object({
|
||||
characterId: z.string().uuid(),
|
||||
characterId: z.number().int().positive(),
|
||||
totalScreenTime: z.number().int(),
|
||||
totalDialogueLines: z.number().int(),
|
||||
sceneCount: z.number().int(),
|
||||
@@ -53,24 +54,15 @@ export const CharacterStatsSchema = z.object({
|
||||
});
|
||||
|
||||
export const SceneSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
content: z.string(),
|
||||
projectId: z.string().uuid(),
|
||||
projectId: z.number().int().positive(),
|
||||
order: z.number().int().nonnegative(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const ScriptVersionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string().uuid(),
|
||||
content: z.string(),
|
||||
version: z.number().int().positive(),
|
||||
authorId: z.string().uuid().optional(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
// Input schemas
|
||||
export const CreateProjectInputSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -78,7 +70,7 @@ export const CreateProjectInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateProjectInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
@@ -98,11 +90,11 @@ export const CreateCharacterInputSchema = z.object({
|
||||
conflict: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
projectId: z.string().uuid(),
|
||||
projectId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const UpdateCharacterInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
@@ -117,12 +109,12 @@ export const UpdateCharacterInputSchema = z.object({
|
||||
conflict: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
projectId: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const CreateRelationshipInputSchema = z.object({
|
||||
characterIdA: z.string().uuid(),
|
||||
characterIdB: z.string().uuid(),
|
||||
characterIdA: z.number().int().positive(),
|
||||
characterIdB: z.number().int().positive(),
|
||||
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
|
||||
description: z.string().optional(),
|
||||
strength: z.number().int().min(0).max(100).optional(),
|
||||
@@ -130,7 +122,7 @@ export const CreateRelationshipInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateRelationshipInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(),
|
||||
description: z.string().optional(),
|
||||
strength: z.number().int().min(0).max(100).optional(),
|
||||
@@ -138,7 +130,7 @@ export const UpdateRelationshipInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const SearchCharactersInputSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
projectId: z.number().int().positive(),
|
||||
query: z.string().optional(),
|
||||
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
|
||||
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
|
||||
@@ -147,12 +139,12 @@ export const SearchCharactersInputSchema = z.object({
|
||||
export const CreateSceneInputSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
projectId: z.string().uuid(),
|
||||
projectId: z.number().int().positive(),
|
||||
order: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const UpdateSceneInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.number().int().positive(),
|
||||
title: z.string().min(1).optional(),
|
||||
content: z.string().optional(),
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
@@ -167,6 +159,7 @@ export const SceneListSchema = z.array(SceneSchema);
|
||||
|
||||
// Auth context
|
||||
export interface TRPCContext {
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
userId?: number;
|
||||
projectId?: number;
|
||||
db?: import('../../src/db/config/migrations').DrizzleDB;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user