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);
|
||||
|
||||
Reference in New Issue
Block a user