diff --git a/server/jest.config.js b/server/jest.config.js index 76052ba..7eb3a91 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -19,6 +19,7 @@ module.exports = { 'src/**/*.{ts,js}', '!**/__tests__/**', '!**/node_modules/**', + '!src/index.ts', '!src/printer/serial-printer.ts', ], coverageThreshold: { diff --git a/server/src/db/repositories/__tests__/in-memory-repository.test.ts b/server/src/db/repositories/__tests__/in-memory-repository.test.ts new file mode 100644 index 0000000..c37a501 --- /dev/null +++ b/server/src/db/repositories/__tests__/in-memory-repository.test.ts @@ -0,0 +1,126 @@ +import { InMemoryRepository, InMemoryPrintHistoryRepository, InMemoryStepRepository } from '../in-memory-repository'; + +describe('InMemoryRepository', () => { + let repository: InMemoryRepository<{ id: number; name: string }>; + + beforeEach(() => { + repository = new InMemoryRepository(); + }); + + it('should create and find items', async () => { + const item = await repository.create({ name: 'test' }); + expect(item.id).toBe(1); + expect(item.name).toBe('test'); + + const found = await repository.findById(item.id); + expect(found).toEqual(item); + }); + + it('should return null for non-existent id', async () => { + const found = await repository.findById(999); + expect(found).toBeNull(); + }); + + it('should update items', async () => { + const item = await repository.create({ name: 'test' }); + const success = await repository.update(item.id, { name: 'updated' }); + expect(success).toBe(true); + + const updated = await repository.findById(item.id); + expect(updated?.name).toBe('updated'); + }); + + it('should return false when updating non-existent item', async () => { + const success = await repository.update(999, { name: 'updated' }); + expect(success).toBe(false); + }); + + it('should delete items', async () => { + const item = await repository.create({ name: 'test' }); + const success = await repository.delete(item.id); + expect(success).toBe(true); + + const found = await repository.findById(item.id); + expect(found).toBeNull(); + }); + + it('should return false when deleting non-existent item', async () => { + const success = await repository.delete(999); + expect(success).toBe(false); + }); + + it('should list all items', async () => { + const item1 = await repository.create({ name: 'test1' }); + const item2 = await repository.create({ name: 'test2' }); + + const all = await repository.findAll(); + expect(all).toHaveLength(2); + expect(all).toContainEqual(item1); + expect(all).toContainEqual(item2); + }); +}); + +describe('InMemoryPrintHistoryRepository', () => { + let repository: InMemoryPrintHistoryRepository; + + beforeEach(() => { + repository = new InMemoryPrintHistoryRepository(); + }); + + it('should find print history by task id', async () => { + const history1 = await repository.create({ task_id: 1, user_id: 1, printed_at: new Date() }); + const history2 = await repository.create({ task_id: 1, user_id: 2, printed_at: new Date() }); + await repository.create({ task_id: 2, user_id: 1, printed_at: new Date() }); + + const taskHistory = await repository.findByTaskId(1); + expect(taskHistory).toHaveLength(2); + expect(taskHistory).toContainEqual(history1); + expect(taskHistory).toContainEqual(history2); + }); + + it('should find print history by step id', async () => { + const history1 = await repository.create({ step_id: 1, user_id: 1, printed_at: new Date() }); + const history2 = await repository.create({ step_id: 1, user_id: 2, printed_at: new Date() }); + await repository.create({ step_id: 2, user_id: 1, printed_at: new Date() }); + + const stepHistory = await repository.findByStepId(1); + expect(stepHistory).toHaveLength(2); + expect(stepHistory).toContainEqual(history1); + expect(stepHistory).toContainEqual(history2); + }); +}); + +describe('InMemoryStepRepository', () => { + let repository: InMemoryStepRepository; + + beforeEach(() => { + repository = new InMemoryStepRepository(); + }); + + it('should find steps by task id', async () => { + const step1 = await repository.create({ task_id: 1, name: 'Step 1', instructions: 'Test', order: 1, print_count: 0 }); + const step2 = await repository.create({ task_id: 1, name: 'Step 2', instructions: 'Test', order: 2, print_count: 0 }); + await repository.create({ task_id: 2, name: 'Step 3', instructions: 'Test', order: 1, print_count: 0 }); + + const taskSteps = await repository.findByTaskId(1); + expect(taskSteps).toHaveLength(2); + expect(taskSteps).toContainEqual(step1); + expect(taskSteps).toContainEqual(step2); + }); + + it('should increment print count', async () => { + const step = await repository.create({ task_id: 1, name: 'Step 1', instructions: 'Test', order: 1, print_count: 0 }); + + const success = await repository.incrementPrintCount(step.id); + expect(success).toBe(true); + + const updated = await repository.findById(step.id); + expect(updated?.print_count).toBe(1); + expect(updated?.last_printed_at).toBeInstanceOf(Date); + }); + + it('should return false when incrementing non-existent step', async () => { + const success = await repository.incrementPrintCount(999); + expect(success).toBe(false); + }); +}); \ No newline at end of file diff --git a/server/src/db/repositories/__tests__/task-repository.test.ts b/server/src/db/repositories/__tests__/task-repository.test.ts new file mode 100644 index 0000000..d4d7f2f --- /dev/null +++ b/server/src/db/repositories/__tests__/task-repository.test.ts @@ -0,0 +1,76 @@ +import { testDb } from '../../__tests__/setup'; +import { TaskRepository } from '../task-repository'; +import { Task } from '@shared/index'; + +describe('TaskRepository (integration)', () => { + let repository: TaskRepository; + let groupId: number; + + beforeEach(async () => { + // Clean up tasks and groups before each test + await testDb('tasks').delete(); + await testDb('groups').delete(); + // Insert a group for foreign key + [groupId] = await testDb('groups').insert({ name: 'Test Group' }); + repository = new TaskRepository(testDb); + }); + + afterAll(async () => { + await testDb.destroy(); + }); + + it('should create and find tasks by group id', async () => { + const task1: Omit = { + name: 'Task 1', + group_id: groupId, + print_count: 0, + last_printed_at: undefined, + }; + const task2: Omit = { + name: 'Task 2', + group_id: groupId, + print_count: 0, + last_printed_at: undefined, + }; + await testDb('tasks').insert([task1, task2]); + const found = await repository.findByGroupId(groupId); + expect(found).toHaveLength(2); + expect(found.map(t => t.name)).toEqual(expect.arrayContaining(['Task 1', 'Task 2'])); + }); + + it('should find recent tasks', async () => { + const now = new Date(); + const task1 = { name: 'Recent 1', group_id: groupId, print_count: 0, last_printed_at: undefined, created_at: new Date(now.getTime() - 2000).toISOString(), updated_at: new Date(now.getTime() - 2000).toISOString() }; + const task2 = { name: 'Recent 2', group_id: groupId, print_count: 0, last_printed_at: undefined, created_at: new Date(now.getTime() - 1000).toISOString(), updated_at: new Date(now.getTime() - 1000).toISOString() }; + const task3 = { name: 'Recent 3', group_id: groupId, print_count: 0, last_printed_at: undefined, created_at: now.toISOString(), updated_at: now.toISOString() }; + await testDb('tasks').insert([task1, task2, task3]); + const found = await repository.findRecent(2); + expect(found).toHaveLength(2); + // Should be ordered by created_at desc, so the most recent first + expect(found[0].name).toBe('Recent 3'); + expect(found[1].name).toBe('Recent 2'); + }); + + it('should find frequent tasks', async () => { + const task1 = { name: 'Frequent 1', group_id: groupId, print_count: 10, last_printed_at: undefined }; + const task2 = { name: 'Frequent 2', group_id: groupId, print_count: 5, last_printed_at: undefined }; + await testDb('tasks').insert([task1, task2]); + const found = await repository.findFrequent(1); + expect(found).toHaveLength(1); + expect(found[0].name).toBe('Frequent 1'); + }); + + it('should increment print count', async () => { + const [id] = await testDb('tasks').insert({ name: 'Print Task', group_id: groupId, print_count: 0, last_printed_at: undefined }); + const result = await repository.incrementPrintCount(id); + expect(result).toBe(true); + const updated = await testDb('tasks').where({ id }).first(); + expect(updated.print_count).toBe(1); + expect(updated.last_printed_at).toBeTruthy(); + }); + + it('should return false when incrementing non-existent task', async () => { + const result = await repository.incrementPrintCount(99999); + expect(result).toBe(false); + }); +}); \ No newline at end of file diff --git a/server/src/db/repositories/base-repository.ts b/server/src/db/repositories/base-repository.ts index 47c35a3..d07bc28 100644 --- a/server/src/db/repositories/base-repository.ts +++ b/server/src/db/repositories/base-repository.ts @@ -1,6 +1,6 @@ import { Knex } from 'knex'; -export abstract class BaseRepository { +export abstract class BaseRepository { protected tableName: string; protected db: Knex; @@ -18,8 +18,16 @@ export abstract class BaseRepository { return result || null; } - async create(data: Omit): Promise { - const [id] = await this.db(this.tableName).insert(data); + async create(data: Omit & Partial>): Promise { + const now = new Date(); + const created_at = (data.created_at ?? now).toISOString(); + const updated_at = (data.updated_at ?? now).toISOString(); + const row = { + ...data, + created_at, + updated_at, + }; + const [id] = await this.db(this.tableName).insert(row); return await this.findById(id) as T; } diff --git a/server/src/db/repositories/in-memory-repository.ts b/server/src/db/repositories/in-memory-repository.ts index ce68004..30fbf45 100644 --- a/server/src/db/repositories/in-memory-repository.ts +++ b/server/src/db/repositories/in-memory-repository.ts @@ -1,4 +1,3 @@ -import { BaseRepository } from './base-repository'; import { PrintHistory, Step } from '@shared/index'; export class InMemoryRepository { diff --git a/server/src/graphql/__tests__/resolvers.test.ts b/server/src/graphql/__tests__/resolvers.test.ts index 16cef06..81d7fce 100644 --- a/server/src/graphql/__tests__/resolvers.test.ts +++ b/server/src/graphql/__tests__/resolvers.test.ts @@ -1,9 +1,8 @@ import { jest } from '@jest/globals'; -import { Knex } from 'knex'; import { testDb } from '../../db/__tests__/setup'; import { resolvers } from '../resolvers'; import { GroupRepository, TaskRepository, StepRepository, UserRepository, NoteRepository, PrintHistoryRepository, ImageRepository } from '../../db/repositories'; -import type { Printer, Task, Step } from '@shared/index'; +import type { Printer } from '@shared/index'; describe('GraphQL Resolvers', () => { const context = { @@ -222,6 +221,33 @@ describe('GraphQL Resolvers', () => { expect(result).toHaveLength(0); }); }); + + describe('recentTasks', () => { + it('should return recent tasks', async () => { + const group = await groupRepo.create({ name: 'Test Group' }); + const oldTimestamp = new Date('2020-01-01T00:00:00.000Z'); + const newTimestamp = new Date('2021-01-01T00:00:00.000Z'); + console.log('timestamps', oldTimestamp, newTimestamp); + await taskRepo.create({ name: 'Old Task', group_id: group.id, print_count: 0, created_at: oldTimestamp, updated_at: oldTimestamp }); + await taskRepo.create({ name: 'New Task', group_id: group.id, print_count: 0, created_at: newTimestamp, updated_at: newTimestamp }); + const result = await resolvers.Query.recentTasks(null, null, context); + console.log('result', JSON.stringify(result, null, 2)); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].name).toBe('New Task'); + expect(result[1].name).toBe('Old Task'); + }); + }); + + describe('frequentTasks', () => { + it('should return frequent tasks', async () => { + const group = await groupRepo.create({ name: 'Test Group' }); + await taskRepo.create({ name: 'Less Frequent', group_id: group.id, print_count: 1 }); + await taskRepo.create({ name: 'More Frequent', group_id: group.id, print_count: 10 }); + const result = await resolvers.Query.frequentTasks(null, null, context); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].name).toBe('More Frequent'); + }); + }); }); describe('Mutations', () => { diff --git a/server/src/graphql/resolvers.ts b/server/src/graphql/resolvers.ts index 47a6f1b..abfab71 100644 --- a/server/src/graphql/resolvers.ts +++ b/server/src/graphql/resolvers.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { GroupRepository, TaskRepository, StepRepository, UserRepository, NoteRepository, PrintHistoryRepository, ImageRepository } from '../db/repositories'; +import { printTask, printStep } from '../printer/helpers'; import type { Printer } from '@shared/index'; interface Context { @@ -148,44 +149,16 @@ export const resolvers = { }); }, printTask: async (_: any, { id, userId }: { id: string; userId: string }, { db, printer }: Context) => { - const taskRepo = new TaskRepository(db); const userRepo = new UserRepository(db); - const printHistoryRepo = new PrintHistoryRepository(db); - const task = await taskRepo.findById(parseInt(id)); - if (!task) throw new Error('Task not found'); const user = await userRepo.findById(parseInt(userId)); if (!user) throw new Error('User not found'); - await printer.printTask(task, db); - await printHistoryRepo.create({ - user_id: parseInt(userId), - task_id: parseInt(id), - printed_at: new Date(), - }); - await taskRepo.update(parseInt(id), { - print_count: (task.print_count || 0) + 1, - last_printed_at: new Date(), - }); - return await taskRepo.findById(parseInt(id)); + return await printTask(parseInt(id), parseInt(userId), db, printer); }, printStep: async (_: any, { id, userId }: { id: string; userId: string }, { db, printer }: Context) => { - const stepRepo = new StepRepository(db); const userRepo = new UserRepository(db); - const printHistoryRepo = new PrintHistoryRepository(db); - const step = await stepRepo.findById(parseInt(id)); - if (!step) throw new Error('Step not found'); const user = await userRepo.findById(parseInt(userId)); if (!user) throw new Error('User not found'); - await printer.printStep(step, db); - await printHistoryRepo.create({ - user_id: parseInt(userId), - step_id: parseInt(id), - printed_at: new Date(), - }); - await stepRepo.update(parseInt(id), { - print_count: (step.print_count || 0) + 1, - last_printed_at: new Date(), - }); - return await stepRepo.findById(parseInt(id)); + return await printStep(parseInt(id), parseInt(userId), db, printer); }, }, diff --git a/server/src/index.ts b/server/src/index.ts index 563b556..375b251 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ import { resolvers } from './graphql/resolvers'; import { createDb } from './db'; import logger from './logger'; import { createPrinter } from './printer'; +import { PrintHistoryRepository, StepRepository } from './db/repositories'; import cors from 'cors'; const app = express(); @@ -14,7 +15,9 @@ const port = process.env.PORT || 4000; async function startServer() { const db = createDb(); - const printer = createPrinter(); + const printHistoryRepo = new PrintHistoryRepository(db); + const stepRepo = new StepRepository(db); + const printer = createPrinter(printHistoryRepo, stepRepo); const server = new ApolloServer({ typeDefs, diff --git a/server/src/printer/helpers.ts b/server/src/printer/helpers.ts new file mode 100644 index 0000000..147774a --- /dev/null +++ b/server/src/printer/helpers.ts @@ -0,0 +1,61 @@ +import { Knex } from 'knex'; +import { Printer, Task, Step } from '@shared/index'; +import { TaskRepository, StepRepository, PrintHistoryRepository } from '../db/repositories'; + +export async function printTask( + taskId: number, + userId: number, + db: Knex, + printer: Printer +): Promise { + const taskRepo = new TaskRepository(db); + const printHistoryRepo = new PrintHistoryRepository(db); + + const task = await taskRepo.findById(taskId); + if (!task) throw new Error('Task not found'); + + await printer.printTask(task, db); + await printHistoryRepo.create({ + user_id: userId, + task_id: taskId, + printed_at: new Date(), + }); + + await taskRepo.update(taskId, { + print_count: (task.print_count || 0) + 1, + last_printed_at: new Date(), + }); + + const updatedTask = await taskRepo.findById(taskId); + if (!updatedTask) throw new Error('Task not found after update'); + return updatedTask; +} + +export async function printStep( + stepId: number, + userId: number, + db: Knex, + printer: Printer +): Promise { + const stepRepo = new StepRepository(db); + const printHistoryRepo = new PrintHistoryRepository(db); + + const step = await stepRepo.findById(stepId); + if (!step) throw new Error('Step not found'); + + await printer.printStep(step, db); + await printHistoryRepo.create({ + user_id: userId, + step_id: stepId, + printed_at: new Date(), + }); + + await stepRepo.update(stepId, { + print_count: (step.print_count || 0) + 1, + last_printed_at: new Date(), + }); + + const updatedStep = await stepRepo.findById(stepId); + if (!updatedStep) throw new Error('Step not found after update'); + return updatedStep; +} \ No newline at end of file diff --git a/server/src/printer/index.ts b/server/src/printer/index.ts index ab220bd..09d0cb5 100644 --- a/server/src/printer/index.ts +++ b/server/src/printer/index.ts @@ -1,5 +1,4 @@ -import { Knex } from 'knex'; -import { Task, Step, Printer } from '@shared/index'; +import { Printer } from '@shared/index'; import { TestPrinter } from './test-printer'; import { SerialPrinter } from './serial-printer'; import logger from '../logger'; @@ -15,19 +14,5 @@ export function createPrinter(printHistoryRepo: PrintHistoryRepository, stepRepo } } -export async function printTask(task: Task, db: Knex): Promise { - const printHistoryRepo = new PrintHistoryRepository(db); - const stepRepo = new StepRepository(db); - const printer = createPrinter(printHistoryRepo, stepRepo); - await printer.printTask(task, db); -} - -export async function printStep(step: Step, db: Knex): Promise { - const printHistoryRepo = new PrintHistoryRepository(db); - const stepRepo = new StepRepository(db); - const printer = createPrinter(printHistoryRepo, stepRepo); - await printer.printStep(step, db); -} - export { SerialPrinter } from './serial-printer'; export { TestPrinter } from './test-printer'; \ No newline at end of file