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:
2026-04-24 08:31:42 -04:00
parent 4d9b4ecf2a
commit 79d153f75a
11 changed files with 443 additions and 352 deletions

View File

@@ -12,25 +12,49 @@
"server:dev": "tsx watch server/websocket/index.ts", "server:dev": "tsx watch server/websocket/index.ts",
"server:build": "tsc -p tsconfig.server.json", "server:build": "tsc -p tsconfig.server.json",
"lint": "eslint src/ server/", "lint": "eslint src/ server/",
"lint:fix": "eslint src/ server/ --fix" "lint:fix": "eslint src/ server/ --fix",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx src/db/seed.ts",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:macos": "TAURI_TARGET=x86_64-apple-darwin tauri build",
"tauri:build:windows": "TAURI_TARGET=x86_64-pc-windows-msvc tauri build",
"tauri:build:linux": "TAURI_TARGET=x86_64-unknown-linux-gnu tauri build"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.3",
"@tanstack/react-query": "^5.100.1",
"@tanstack/solid-query": "^5.100.1",
"@trpc/client": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@types/node": "^25.6.0",
"@types/peerjs": "^0.0.30",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"peerjs": "^1.5.5",
"solid-js": "^1.8.14", "solid-js": "^1.8.14",
"yjs": "^13.6.12", "ws": "^8.16.0",
"y-websocket": "^1.5.0", "y-websocket": "^1.5.0",
"ws": "^8.16.0" "yjs": "^13.6.12",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/better-sqlite3": "^7.6.13",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2", "@typescript-eslint/parser": "^7.0.2",
"better-sqlite3": "^12.9.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-solid": "^0.13.2", "eslint-plugin-solid": "^0.13.2",
"tsx": "^4.7.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-solid": "^2.8.2", "vite-plugin-solid": "^2.8.2",
"vitest": "^1.3.1", "vitest": "^1.3.1"
"tsx": "^4.7.1"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

View File

@@ -1,38 +1,41 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index'; import { appRouter } from './index';
import { getTestDb, resetTestDb } from './test-setup';
import type { TRPCContext } from './types';
describe('tRPC API Layer - Character System', () => { describe('tRPC API Layer - Character System', () => {
let ctx: { userId: string }; let ctx: TRPCContext;
let projectId: string; let caller: ReturnType<typeof appRouter.createCaller>;
let projectId: number;
beforeEach(async () => { beforeEach(async () => {
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; await resetTestDb();
const project = await appRouter.project.createProject.mutate({ const db = await getTestDb();
input: { name: 'Character System Test Project' }, ctx = { userId: 1, db };
ctx, caller = appRouter.createCaller(ctx);
const project = await caller.project.createProject({
name: 'Character System Test Project',
}); });
projectId = project.id; projectId = project.id;
}); });
describe('createCharacter', () => { describe('createCharacter', () => {
it('should create a character with all profile fields', async () => { it('should create a character with all profile fields', async () => {
const character = await appRouter.project.createCharacter.mutate({ const character = await caller.project.createCharacter({
input: { name: 'John Doe',
name: 'John Doe', bio: 'A brave hero',
bio: 'A brave hero', role: 'protagonist',
role: 'protagonist', arc: 'Grows from coward to leader',
arc: 'Grows from coward to leader', arcType: 'positive',
arcType: 'positive', age: 30,
age: 30, gender: 'male',
gender: 'male', voice: 'Deep, commanding',
voice: 'Deep, commanding', traits: 'Brave, loyal, stubborn',
traits: 'Brave, loyal, stubborn', motivation: 'Protect his family',
motivation: 'Protect his family', conflict: 'Internal fear of failure',
conflict: 'Internal fear of failure', secret: 'Afraid of heights',
secret: 'Afraid of heights', projectId,
projectId,
},
ctx,
}); });
expect(character).toMatchObject({ expect(character).toMatchObject({
@@ -47,12 +50,9 @@ describe('tRPC API Layer - Character System', () => {
}); });
it('should default role to supporting when not provided', async () => { it('should default role to supporting when not provided', async () => {
const character = await appRouter.project.createCharacter.mutate({ const character = await caller.project.createCharacter({
input: { name: 'Jane Smith',
name: 'Jane Smith', projectId,
projectId,
},
ctx,
}); });
expect(character.role).toBe('supporting'); expect(character.role).toBe('supporting');
@@ -61,19 +61,16 @@ describe('tRPC API Layer - Character System', () => {
describe('updateCharacter', () => { describe('updateCharacter', () => {
it('should update character profile fields', async () => { it('should update character profile fields', async () => {
const created = await appRouter.project.createCharacter.mutate({ const created = await caller.project.createCharacter({
input: { name: 'Original', projectId }, name: 'Original',
ctx, projectId,
}); });
const updated = await appRouter.project.updateCharacter.mutate({ const updated = await caller.project.updateCharacter({
input: { id: created.id,
id: created.id, name: 'Updated Name',
name: 'Updated Name', bio: 'New bio',
bio: 'New bio', role: 'antagonist',
role: 'antagonist',
},
ctx,
}); });
expect(updated.name).toBe('Updated Name'); expect(updated.name).toBe('Updated Name');
@@ -85,18 +82,20 @@ describe('tRPC API Layer - Character System', () => {
describe('searchCharacters', () => { describe('searchCharacters', () => {
it('should filter characters by query', async () => { it('should filter characters by query', async () => {
await appRouter.project.createCharacter.mutate({ await caller.project.createCharacter({
input: { name: 'Alice', bio: 'The hero', projectId }, name: 'Alice',
ctx, bio: 'The hero',
projectId,
}); });
await appRouter.project.createCharacter.mutate({ await caller.project.createCharacter({
input: { name: 'Bob', bio: 'The villain', projectId }, name: 'Bob',
ctx, bio: 'The villain',
projectId,
}); });
const results = await appRouter.project.searchCharacters.query({ const results = await caller.project.searchCharacters({
input: { projectId, query: 'hero' }, projectId,
ctx, query: 'hero',
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -104,18 +103,20 @@ describe('tRPC API Layer - Character System', () => {
}); });
it('should filter characters by role', async () => { it('should filter characters by role', async () => {
await appRouter.project.createCharacter.mutate({ await caller.project.createCharacter({
input: { name: 'Protag', role: 'protagonist', projectId }, name: 'Protag',
ctx, role: 'protagonist',
projectId,
}); });
await appRouter.project.createCharacter.mutate({ await caller.project.createCharacter({
input: { name: 'Antag', role: 'antagonist', projectId }, name: 'Antag',
ctx, role: 'antagonist',
projectId,
}); });
const results = await appRouter.project.searchCharacters.query({ const results = await caller.project.searchCharacters({
input: { projectId, role: 'protagonist' }, projectId,
ctx, role: 'protagonist',
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -125,24 +126,21 @@ describe('tRPC API Layer - Character System', () => {
describe('createRelationship', () => { describe('createRelationship', () => {
it('should create a relationship between two characters', async () => { it('should create a relationship between two characters', async () => {
const charA = await appRouter.project.createCharacter.mutate({ const charA = await caller.project.createCharacter({
input: { name: 'Character A', projectId }, name: 'Character A',
ctx, projectId,
}); });
const charB = await appRouter.project.createCharacter.mutate({ const charB = await caller.project.createCharacter({
input: { name: 'Character B', projectId }, name: 'Character B',
ctx, projectId,
}); });
const rel = await appRouter.project.createRelationship.mutate({ const rel = await caller.project.createRelationship({
input: { characterIdA: charA.id,
characterIdA: charA.id, characterIdB: charB.id,
characterIdB: charB.id, relationshipType: 'friendship',
relationshipType: 'friendship', strength: 80,
strength: 80, isAntagonistic: false,
isAntagonistic: false,
},
ctx,
}); });
expect(rel.characterIdA).toBe(charA.id); expect(rel.characterIdA).toBe(charA.id);
@@ -152,50 +150,41 @@ describe('tRPC API Layer - Character System', () => {
}); });
it('should prevent self-relationships', async () => { it('should prevent self-relationships', async () => {
const charA = await appRouter.project.createCharacter.mutate({ const charA = await caller.project.createCharacter({
input: { name: 'Character A', projectId }, name: 'Character A',
ctx, projectId,
}); });
await expect( await expect(
appRouter.project.createRelationship.mutate({ caller.project.createRelationship({
input: { characterIdA: charA.id,
characterIdA: charA.id, characterIdB: charA.id,
characterIdB: charA.id, relationshipType: 'friendship',
relationshipType: 'friendship',
},
ctx,
}) })
).rejects.toThrow('Cannot create a relationship with the same character'); ).rejects.toThrow('Cannot create a relationship with the same character');
}); });
it('should prevent duplicate relationships', async () => { it('should prevent duplicate relationships', async () => {
const charA = await appRouter.project.createCharacter.mutate({ const charA = await caller.project.createCharacter({
input: { name: 'Character A', projectId }, name: 'Character A',
ctx, projectId,
}); });
const charB = await appRouter.project.createCharacter.mutate({ const charB = await caller.project.createCharacter({
input: { name: 'Character B', projectId }, name: 'Character B',
ctx, projectId,
}); });
await appRouter.project.createRelationship.mutate({ await caller.project.createRelationship({
input: { characterIdA: charA.id,
characterIdA: charA.id, characterIdB: charB.id,
characterIdB: charB.id, relationshipType: 'friendship',
relationshipType: 'friendship',
},
ctx,
}); });
await expect( await expect(
appRouter.project.createRelationship.mutate({ caller.project.createRelationship({
input: { characterIdA: charA.id,
characterIdA: charA.id, characterIdB: charB.id,
characterIdB: charB.id, relationshipType: 'rivalry',
relationshipType: 'rivalry',
},
ctx,
}) })
).rejects.toThrow('Relationship already exists between these characters'); ).rejects.toThrow('Relationship already exists between these characters');
}); });
@@ -203,32 +192,25 @@ describe('tRPC API Layer - Character System', () => {
describe('deleteCharacter', () => { describe('deleteCharacter', () => {
it('should remove associated relationships when deleting a character', async () => { it('should remove associated relationships when deleting a character', async () => {
const charA = await appRouter.project.createCharacter.mutate({ const charA = await caller.project.createCharacter({
input: { name: 'Character A', projectId }, name: 'Character A',
ctx, projectId,
}); });
const charB = await appRouter.project.createCharacter.mutate({ const charB = await caller.project.createCharacter({
input: { name: 'Character B', projectId }, name: 'Character B',
ctx, projectId,
}); });
await appRouter.project.createRelationship.mutate({ await caller.project.createRelationship({
input: { characterIdA: charA.id,
characterIdA: charA.id, characterIdB: charB.id,
characterIdB: charB.id, relationshipType: 'friendship',
relationshipType: 'friendship',
},
ctx,
}); });
await appRouter.project.deleteCharacter.mutate({ await caller.project.deleteCharacter({ id: charA.id });
input: { id: charA.id },
ctx,
});
const rels = await appRouter.project.getRelationshipsForCharacter.query({ const rels = await caller.project.getRelationshipsForCharacter({
input: { characterId: charB.id }, characterId: charB.id,
ctx,
}); });
expect(rels.length).toBe(0); expect(rels.length).toBe(0);
@@ -237,14 +219,13 @@ describe('tRPC API Layer - Character System', () => {
describe('getCharacterStats', () => { describe('getCharacterStats', () => {
it('should return stats for a character', async () => { it('should return stats for a character', async () => {
const charA = await appRouter.project.createCharacter.mutate({ const charA = await caller.project.createCharacter({
input: { name: 'TestChar', projectId }, name: 'TestChar',
ctx, projectId,
}); });
const stats = await appRouter.project.getCharacterStats.query({ const stats = await caller.project.getCharacterStats({
input: { characterId: charA.id }, characterId: charA.id,
ctx,
}); });
expect(stats.characterId).toBe(charA.id); expect(stats.characterId).toBe(charA.id);

View File

@@ -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 { projectRouter } from './project-router';
import { revisionsRouter } from './revisions-router'; import { revisionsRouter } from './revisions-router';
import type { TRPCContext } from './types'; import type { TRPCContext } from './types';
@@ -15,22 +15,15 @@ export type AppRouter = typeof appRouter;
// Create tRPC HTTP server // Create tRPC HTTP server
export function createTRPCServer(port: number = 8080) { export function createTRPCServer(port: number = 8080) {
const server = initHTTPServer({ const server = createHTTPServer({
router: appRouter, router: appRouter,
createContext: async ({ req }: { req: Request }): Promise<TRPCContext> => { createContext: async (): Promise<TRPCContext> => {
// Extract auth from headers
const authHeader = req.headers.get('authorization');
const userId = authHeader?.split(' ')[1]; // Bearer token
return { return {
userId, userId: undefined,
}; };
}, },
onError: ({ error, path, input }: { error: TRPCError; path: string; input: unknown }) => { onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {
console.error(`tRPC error on ${path}:`, { console.error(`tRPC error on ${path}:`, error.message);
input,
error: error.message,
});
}, },
}); });

View File

@@ -1,71 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index'; 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', () => { describe('tRPC API Layer', () => {
let ctx: { userId: string }; let ctx: TRPCContext;
let caller: ReturnType<typeof appRouter.createCaller>;
let projectId: number;
beforeEach(() => { beforeEach(async () => {
ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' }; await resetTestDb();
const db = await getTestDb();
ctx = { userId: 1, db };
caller = appRouter.createCaller(ctx);
}); });
describe('Project CRUD', () => { describe('Project CRUD', () => {
it('should create a project', async () => { it('should create a project', async () => {
const project = await appRouter.project.createProject.mutate({ const project = await caller.project.createProject({
input: { name: 'Test Project',
name: 'Test Project', description: 'A test project',
description: 'A test project',
},
ctx,
}); });
expect(project).toMatchObject({ expect(project).toMatchObject({
name: 'Test Project', name: 'Test Project',
description: 'A test project', description: 'A test project',
userId: ctx.userId, ownerId: ctx.userId,
}); });
expect(project.id).toBeDefined(); expect(project.id).toBeDefined();
expect(project.id).toBeGreaterThan(0);
expect(project.createdAt).toBeInstanceOf(Date); expect(project.createdAt).toBeInstanceOf(Date);
expect(project.updatedAt).toBeInstanceOf(Date); expect(project.updatedAt).toBeInstanceOf(Date);
}); });
it('should list projects', async () => { it('should list projects', async () => {
const projects = await appRouter.project.listProjects.query({ await caller.project.createProject({ name: 'Test Project' });
ctx: { userId: ctx.userId },
}); const projects = await caller.project.listProjects();
expect(Array.isArray(projects)).toBe(true); expect(Array.isArray(projects)).toBe(true);
expect(projects.length).toBeGreaterThan(0);
}); });
it('should get a specific project', async () => { it('should get a specific project', async () => {
// First create a project const created = await caller.project.createProject({ name: 'Get Test' });
const created = await appRouter.project.createProject.mutate({
input: { name: 'Get Test' },
ctx,
});
const project = await appRouter.project.getProject.query({ const project = await caller.project.getProject({ id: created.id });
input: { id: created.id },
ctx,
});
expect(project.id).toBe(created.id); expect(project.id).toBe(created.id);
expect(project.name).toBe('Get Test'); expect(project.name).toBe('Get Test');
}); });
it('should update a project', async () => { it('should update a project', async () => {
const created = await appRouter.project.createProject.mutate({ const created = await caller.project.createProject({ name: 'Update Test' });
input: { name: 'Update Test' },
ctx,
});
const updated = await appRouter.project.updateProject.mutate({ const updated = await caller.project.updateProject({
input: { id: created.id,
id: created.id, name: 'Updated Test',
name: 'Updated Test', description: 'Updated description',
description: 'Updated description',
},
ctx,
}); });
expect(updated.name).toBe('Updated Test'); expect(updated.name).toBe('Updated Test');
@@ -73,39 +65,27 @@ describe('tRPC API Layer', () => {
}); });
it('should delete a project', async () => { it('should delete a project', async () => {
const created = await appRouter.project.createProject.mutate({ const created = await caller.project.createProject({ name: 'Delete Test' });
input: { name: 'Delete Test' },
ctx,
});
const result = await appRouter.project.deleteProject.mutate({ const result = await caller.project.deleteProject({ id: created.id });
input: { id: created.id },
ctx,
});
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
}); });
describe('Character CRUD', () => { describe('Character CRUD', () => {
let projectId: string;
beforeEach(async () => { beforeEach(async () => {
const project = await appRouter.project.createProject.mutate({ const project = await caller.project.createProject({
input: { name: 'Character Test Project' }, name: 'Character Test Project',
ctx,
}); });
projectId = project.id; projectId = project.id;
}); });
it('should create a character', async () => { it('should create a character', async () => {
const character = await appRouter.project.createCharacter.mutate({ const character = await caller.project.createCharacter({
input: { name: 'John Doe',
name: 'John Doe', description: 'Main character',
description: 'Main character', projectId,
projectId,
},
ctx,
}); });
expect(character).toMatchObject({ expect(character).toMatchObject({
@@ -116,40 +96,28 @@ describe('tRPC API Layer', () => {
}); });
it('should list characters for a project', async () => { it('should list characters for a project', async () => {
await appRouter.project.createCharacter.mutate({ await caller.project.createCharacter({ name: 'Char 1', projectId });
input: { name: 'Char 1', projectId },
ctx,
});
const characters = await appRouter.project.listCharacters.query({ const characters = await caller.project.listCharacters({ projectId });
input: { projectId },
ctx,
});
expect(characters.length).toBeGreaterThan(0); expect(characters.length).toBeGreaterThan(0);
}); });
}); });
describe('Scene CRUD', () => { describe('Scene CRUD', () => {
let projectId: string;
beforeEach(async () => { beforeEach(async () => {
const project = await appRouter.project.createProject.mutate({ const project = await caller.project.createProject({
input: { name: 'Scene Test Project' }, name: 'Scene Test Project',
ctx,
}); });
projectId = project.id; projectId = project.id;
}); });
it('should create a scene', async () => { it('should create a scene', async () => {
const scene = await appRouter.project.createScene.mutate({ const scene = await caller.project.createScene({
input: { title: 'INT. OFFICE - DAY',
title: 'INT. OFFICE - DAY', content: 'John sits at his desk.',
content: 'John sits at his desk.', projectId,
projectId, order: 1,
order: 1,
},
ctx,
}); });
expect(scene).toMatchObject({ expect(scene).toMatchObject({
@@ -161,29 +129,21 @@ describe('tRPC API Layer', () => {
}); });
it('should list scenes for a project', async () => { it('should list scenes for a project', async () => {
await appRouter.project.createScene.mutate({ await caller.project.createScene({ title: 'Scene 1', projectId, order: 1 });
input: { title: 'Scene 1', projectId, order: 1 },
ctx,
});
const scenes = await appRouter.project.listScenes.query({ const scenes = await caller.project.listScenes({ projectId });
input: { projectId },
ctx,
});
expect(scenes.length).toBeGreaterThan(0); expect(scenes.length).toBeGreaterThan(0);
}); });
it('should update scene order', async () => { it('should update scene order', async () => {
const scene = await appRouter.project.createScene.mutate({ const scene = await caller.project.createScene({
input: { title: 'Reorder Scene', projectId, order: 1 }, title: 'Reorder Scene',
ctx, projectId,
order: 1,
}); });
const updated = await appRouter.project.updateScene.mutate({ const updated = await caller.project.updateScene({ id: scene.id, order: 5 });
input: { id: scene.id, order: 5 },
ctx,
});
expect(updated.order).toBe(5); expect(updated.order).toBe(5);
}); });
@@ -192,20 +152,14 @@ describe('tRPC API Layer', () => {
describe('Error Handling', () => { describe('Error Handling', () => {
it('should throw error when getting non-existent project', async () => { it('should throw error when getting non-existent project', async () => {
await expect( await expect(
appRouter.project.getProject.query({ caller.project.getProject({ id: 99999 })
input: { id: '00000000-0000-0000-0000-000000000000' },
ctx,
})
).rejects.toThrow('not found'); ).rejects.toThrow('not found');
}); });
it('should throw error when deleting non-existent project', async () => { it('should throw error when deleting non-existent project', async () => {
const result = await appRouter.project.deleteProject.mutate({ await expect(
input: { id: '00000000-0000-0000-0000-000000000000' }, caller.project.deleteProject({ id: 99999 })
ctx, ).rejects.toThrow('not found');
});
expect(result).toEqual({ success: false });
}); });
}); });
}); });

View File

@@ -172,12 +172,15 @@ export const projectRouter = {
}), }),
// Character CRUD procedures // Character CRUD procedures
listCharacters: projectProcedure.query(async ({ ctx }) => { listCharacters: protectedProcedure
return await ctx.db!.select() .input(z.object({ projectId: z.number().int().positive() }))
.from(characters) .query(async ({ input, ctx }) => {
.where(eq(characters.projectId, ctx.projectId!)) await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
.orderBy(characters.name); return await ctx.db!.select()
}), .from(characters)
.where(eq(characters.projectId, input.projectId))
.orderBy(characters.name);
}),
getCharacter: protectedProcedure getCharacter: protectedProcedure
.input(z.object({ id: z.number().int().positive() })) .input(z.object({ id: z.number().int().positive() }))
@@ -529,12 +532,15 @@ export const projectRouter = {
}), }),
// Scene procedures // Scene procedures
listScenes: projectProcedure.query(async ({ ctx }) => { listScenes: protectedProcedure
return await ctx.db!.select() .input(z.object({ projectId: z.number().int().positive() }))
.from(scenes) .query(async ({ input, ctx }) => {
.where(eq(scenes.projectId, ctx.projectId!)) await verifyProjectOwnership(ctx.db!, input.projectId, ctx.userId!);
.orderBy(scenes.order); return await ctx.db!.select()
}), .from(scenes)
.where(eq(scenes.projectId, input.projectId))
.orderBy(scenes.order);
}),
getScene: protectedProcedure getScene: protectedProcedure
.input(z.object({ id: z.number().int().positive() })) .input(z.object({ id: z.number().int().positive() }))

View File

@@ -13,22 +13,31 @@ const isAuthenticated = t.middleware(({ ctx, next }) => {
return next({ ctx: { ...ctx, userId: ctx.userId } }); return next({ ctx: { ...ctx, userId: ctx.userId } });
}); });
// Middleware for project authorization // Middleware for database access
const hasProjectAccess = t.middleware(({ ctx, next }) => { const hasDb = t.middleware(({ ctx, next }) => {
const projectId = ctx.projectId; if (!ctx.db) {
if (!projectId) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
throw new TRPCError({ code: 'FORBIDDEN', message: 'Project access required' });
} }
return next({ ctx: { ...ctx, projectId } }); return next({ ctx: { ...ctx, db: ctx.db } });
}); });
// Base router // Base router
export const baseRouter = t.router; export const baseRouter = t.router;
// Procedure builders // Procedure builders
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure.use(hasDb);
export const protectedProcedure = t.procedure.use(isAuthenticated); export const protectedProcedure = t.procedure.use(isAuthenticated).use(hasDb);
export const projectProcedure = t.procedure.use(hasProjectAccess); 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 // Validation middleware
export const validateInput = <T extends z.ZodTypeAny>(schema: T) => { export const validateInput = <T extends z.ZodTypeAny>(schema: T) => {

133
server/trpc/test-setup.ts Normal file
View 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();
}

View File

@@ -1,43 +1,44 @@
import { z } from 'zod'; import { z } from 'zod';
// Base types // Base types - IDs are integers matching Drizzle schema
export const ProjectSchema = z.object({ export const ProjectSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().nullable(),
userId: z.string().uuid(), userId: z.number().int().positive(),
isPublic: z.boolean(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}); });
export const CharacterSchema = z.object({ export const CharacterSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
slug: z.string(), slug: z.string(),
description: z.string().optional(), description: z.string().nullable(),
bio: z.string().optional(), bio: z.string().nullable(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']),
arc: z.string().optional(), arc: z.string().nullable(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).nullable(),
age: z.number().int().optional(), age: z.number().int().nullable(),
gender: z.string().optional(), gender: z.string().nullable(),
voice: z.string().optional(), voice: z.string().nullable(),
traits: z.string().optional(), traits: z.string().nullable(),
motivation: z.string().optional(), motivation: z.string().nullable(),
conflict: z.string().optional(), conflict: z.string().nullable(),
secret: z.string().optional(), secret: z.string().nullable(),
imageUrl: z.string().url().optional(), imageUrl: z.string().nullable(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}); });
export const CharacterRelationshipSchema = z.object({ export const CharacterRelationshipSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
characterIdA: z.string().uuid(), characterIdA: z.number().int().positive(),
characterIdB: z.string().uuid(), characterIdB: z.number().int().positive(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), 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), strength: z.number().int().min(0).max(100),
isAntagonistic: z.boolean(), isAntagonistic: z.boolean(),
createdAt: z.date(), createdAt: z.date(),
@@ -45,7 +46,7 @@ export const CharacterRelationshipSchema = z.object({
}); });
export const CharacterStatsSchema = z.object({ export const CharacterStatsSchema = z.object({
characterId: z.string().uuid(), characterId: z.number().int().positive(),
totalScreenTime: z.number().int(), totalScreenTime: z.number().int(),
totalDialogueLines: z.number().int(), totalDialogueLines: z.number().int(),
sceneCount: z.number().int(), sceneCount: z.number().int(),
@@ -53,24 +54,15 @@ export const CharacterStatsSchema = z.object({
}); });
export const SceneSchema = z.object({ export const SceneSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
title: z.string().min(1), title: z.string().min(1),
content: z.string(), content: z.string(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
order: z.number().int().nonnegative(), order: z.number().int().nonnegative(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: 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 // Input schemas
export const CreateProjectInputSchema = z.object({ export const CreateProjectInputSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@@ -78,7 +70,7 @@ export const CreateProjectInputSchema = z.object({
}); });
export const UpdateProjectInputSchema = z.object({ export const UpdateProjectInputSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
}); });
@@ -98,11 +90,11 @@ export const CreateCharacterInputSchema = z.object({
conflict: z.string().optional(), conflict: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
imageUrl: z.string().url().optional(), imageUrl: z.string().url().optional(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
}); });
export const UpdateCharacterInputSchema = z.object({ export const UpdateCharacterInputSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
description: z.string().optional(), description: z.string().optional(),
bio: z.string().optional(), bio: z.string().optional(),
@@ -117,12 +109,12 @@ export const UpdateCharacterInputSchema = z.object({
conflict: z.string().optional(), conflict: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
imageUrl: z.string().url().optional(), imageUrl: z.string().url().optional(),
projectId: z.string().uuid().optional(), projectId: z.number().int().positive().optional(),
}); });
export const CreateRelationshipInputSchema = z.object({ export const CreateRelationshipInputSchema = z.object({
characterIdA: z.string().uuid(), characterIdA: z.number().int().positive(),
characterIdB: z.string().uuid(), characterIdB: z.number().int().positive(),
relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']),
description: z.string().optional(), description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(), strength: z.number().int().min(0).max(100).optional(),
@@ -130,7 +122,7 @@ export const CreateRelationshipInputSchema = z.object({
}); });
export const UpdateRelationshipInputSchema = 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(), relationshipType: z.enum(['family', 'romantic', 'friendship', 'rivalry', 'mentor', 'alliance', 'conflict', 'professional', 'other']).optional(),
description: z.string().optional(), description: z.string().optional(),
strength: z.number().int().min(0).max(100).optional(), strength: z.number().int().min(0).max(100).optional(),
@@ -138,7 +130,7 @@ export const UpdateRelationshipInputSchema = z.object({
}); });
export const SearchCharactersInputSchema = z.object({ export const SearchCharactersInputSchema = z.object({
projectId: z.string().uuid(), projectId: z.number().int().positive(),
query: z.string().optional(), query: z.string().optional(),
role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(), role: z.enum(['protagonist', 'antagonist', 'supporting', 'background', 'ensemble']).optional(),
arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(), arcType: z.enum(['positive', 'negative', 'flat', 'complex']).optional(),
@@ -147,12 +139,12 @@ export const SearchCharactersInputSchema = z.object({
export const CreateSceneInputSchema = z.object({ export const CreateSceneInputSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
content: z.string().optional(), content: z.string().optional(),
projectId: z.string().uuid(), projectId: z.number().int().positive(),
order: z.number().int().nonnegative(), order: z.number().int().nonnegative(),
}); });
export const UpdateSceneInputSchema = z.object({ export const UpdateSceneInputSchema = z.object({
id: z.string().uuid(), id: z.number().int().positive(),
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
content: z.string().optional(), content: z.string().optional(),
order: z.number().int().nonnegative().optional(), order: z.number().int().nonnegative().optional(),
@@ -167,6 +159,7 @@ export const SceneListSchema = z.array(SceneSchema);
// Auth context // Auth context
export interface TRPCContext { export interface TRPCContext {
userId?: string; userId?: number;
projectId?: string; projectId?: number;
db?: import('../../src/db/config/migrations').DrizzleDB;
} }

View File

@@ -1,35 +1,20 @@
import type { z } from 'zod'; import type {
import { Project as DrizzleProject,
ProjectSchema, Character as DrizzleCharacter,
CharacterSchema, CharacterRelationship as DrizzleCharacterRelationship,
CharacterRelationshipSchema, Scene as DrizzleScene,
CharacterStatsSchema, } from '../../src/db/schema';
SceneSchema,
ScriptVersionSchema,
CreateProjectInputSchema,
UpdateProjectInputSchema,
CreateCharacterInputSchema,
UpdateCharacterInputSchema,
CreateRelationshipInputSchema,
UpdateRelationshipInputSchema,
SearchCharactersInputSchema,
CreateSceneInputSchema,
UpdateSceneInputSchema,
} from '../trpc/types';
export type Project = z.infer<typeof ProjectSchema>; // Re-export Drizzle types as the canonical types
export type Character = z.infer<typeof CharacterSchema>; export type Project = DrizzleProject;
export type CharacterRelationship = z.infer<typeof CharacterRelationshipSchema>; export type Character = DrizzleCharacter;
export type CharacterStats = z.infer<typeof CharacterStatsSchema>; export type CharacterRelationship = DrizzleCharacterRelationship;
export type Scene = z.infer<typeof SceneSchema>; export type Scene = DrizzleScene;
export type ScriptVersion = z.infer<typeof ScriptVersionSchema>;
export type CreateProjectInput = z.infer<typeof CreateProjectInputSchema>; export interface CharacterStats {
export type UpdateProjectInput = z.infer<typeof UpdateProjectInputSchema>; characterId: number;
export type CreateCharacterInput = z.infer<typeof CreateCharacterInputSchema>; totalScreenTime: number;
export type UpdateCharacterInput = z.infer<typeof UpdateCharacterInputSchema>; totalDialogueLines: number;
export type CreateRelationshipInput = z.infer<typeof CreateRelationshipInputSchema>; sceneCount: number;
export type UpdateRelationshipInput = z.infer<typeof UpdateRelationshipInputSchema>; relationshipCount: number;
export type SearchCharactersInput = z.infer<typeof SearchCharactersInputSchema>; }
export type CreateSceneInput = z.infer<typeof CreateSceneInputSchema>;
export type UpdateSceneInput = z.infer<typeof UpdateSceneInputSchema>;

View File

@@ -30,8 +30,4 @@ export default defineConfig({
}, },
}, },
}, },
test: {
globals: true,
environment: 'node',
},
}); });

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
deps: {
interopDefault: true,
},
},
optimizeDeps: {
include: ['ws'],
},
ssr: {
noExternal: ['ws'],
},
});