diff --git a/__tests__/TimerPage.resume.test.tsx b/__tests__/TimerPage.resume.test.tsx new file mode 100644 index 0000000..5f02381 --- /dev/null +++ b/__tests__/TimerPage.resume.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import TimerPage from '../app/timer/page'; + +// Mock fetch for clients and /api/timer +beforeEach(() => { + global.fetch = jest.fn((url) => { + if (typeof url === 'string' && url.endsWith('/api/timer')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ runningEntry: { id: 'entry1', clientId: 'client1', startTime: new Date(Date.now() - 5000).toISOString(), client: { id: 'client1', name: 'Client A', hourlyRate: 50 } } }) }); + } + if (typeof url === 'string' && url.endsWith('/api/clients')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ clients: [{ id: 'client1', name: 'Client A', hourlyRate: 50 }] }) }); + } + return Promise.resolve({ ok: false }); + }) as jest.Mock; +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('TimerPage resume behavior', () => { + it('resumes UI when runningEntry exists', async () => { + const qc = new QueryClient(); + render( + + + , + ); + + // Wait for the timer display to show a non-zero time + const timerDisplay = await screen.findByText(/\d{2}:\d{2}:\d{2}/); + expect(timerDisplay.textContent).not.toBe('00:00:00'); + + // Ensure Stop button is present (implies running) + expect(await screen.findByText(/stop/i)).toBeInTheDocument(); + }); +}); diff --git a/__tests__/api/timer.get.test.ts b/__tests__/api/timer.get.test.ts new file mode 100644 index 0000000..7bd29a0 --- /dev/null +++ b/__tests__/api/timer.get.test.ts @@ -0,0 +1,49 @@ +import { GET } from '../../app/api/timer/route'; +import { getServerSession } from 'next-auth'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { upsert: jest.fn() }, + client: { findFirst: jest.fn() }, + timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, + }, +})); +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })); + +const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as { + prisma: { + user: { upsert: jest.Mock }; + client: { findFirst: jest.Mock }; + timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; + }; +}; + +describe('GET /api/timer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns runningEntry when present', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.timeEntry.findFirst.mockResolvedValue({ id: 'entry1', clientId: 'client1', startTime: new Date().toISOString(), status: 'RUNNING', client: { id: 'client1', name: 'C' } }); + + const res = await GET(); + const json = await res.json(); + + expect(json.runningEntry).toBeDefined(); + expect(json.runningEntry.status).toBe('RUNNING'); + expect(json.runningEntry.client).toBeDefined(); + }); + + it('returns null when no running entry', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.timeEntry.findFirst.mockResolvedValue(null); + + const res = await GET(); + const json = await res.json(); + + expect(json.runningEntry).toBeNull(); + }); +}); diff --git a/__tests__/api/timer.post.test.ts b/__tests__/api/timer.post.test.ts new file mode 100644 index 0000000..66d6cbf --- /dev/null +++ b/__tests__/api/timer.post.test.ts @@ -0,0 +1,74 @@ +// Mock prisma, next-auth, auth and env before importing the route module +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { upsert: jest.fn() }, + client: { findFirst: jest.fn() }, + timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, + }, +})); +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })); +jest.mock('@/auth', () => ({ authOptions: {} })); +jest.mock('@/lib/env', () => ({ validateEnv: () => {} })); + +const { getServerSession } = jest.requireMock('next-auth') as { getServerSession: jest.Mock }; +let POST: (req: Request) => Promise; +beforeAll(async () => { + const mod = await import('../../app/api/timer/route'); + POST = mod.POST; +}); + +const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as { + prisma: { + user: { upsert: jest.Mock }; + client: { findFirst: jest.Mock }; + timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; + }; +}; + +describe('POST /api/timer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('creates a running time entry when none exists', async () => { + // Mock session + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com', name: 'Test' } }); + + // Mock upsert user + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + // Mock client lookup + mockPrisma.client.findFirst.mockResolvedValue({ id: 'client1', userId: 'user1' }); + // No existing running entry + mockPrisma.timeEntry.findFirst.mockResolvedValue(null); + // Create returns new entry + mockPrisma.timeEntry.create.mockResolvedValue({ id: 'entry1', startTime: new Date().toISOString(), clientId: 'client1', userId: 'user1', status: 'RUNNING' }); + + const req = new Request('http://localhost/api/timer', { + method: 'POST', + body: JSON.stringify({ clientId: 'client1', startTime: new Date().toISOString() }), + }); + + const res = await POST(req as Request); + const json = await res.json(); + + expect(json.timeEntry).toBeDefined(); + expect(json.timeEntry.status).toBe('RUNNING'); + }); + + it('returns 409 when a running timer already exists', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com', name: 'Test' } }); + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.client.findFirst.mockResolvedValue({ id: 'client1', userId: 'user1' }); + mockPrisma.timeEntry.findFirst.mockResolvedValue({ id: 'existing', userId: 'user1', status: 'RUNNING' }); + + const req = new Request('http://localhost/api/timer', { + method: 'POST', + body: JSON.stringify({ clientId: 'client1', startTime: new Date().toISOString() }), + }); + + const res = await POST(req as Request); + const json = await res.json(); + + expect(json.error).toBe('A timer is already running'); + }); +}); diff --git a/__tests__/api/timer.put.test.ts b/__tests__/api/timer.put.test.ts new file mode 100644 index 0000000..3bc9ac9 --- /dev/null +++ b/__tests__/api/timer.put.test.ts @@ -0,0 +1,59 @@ +import { PUT } from '../../app/api/timer/[id]/route'; +import { getServerSession } from 'next-auth'; + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { upsert: jest.fn() }, + client: { findFirst: jest.fn() }, + timeEntry: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, + }, +})); +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })); + +const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as { + prisma: { + user: { upsert: jest.Mock }; + client: { findFirst: jest.Mock }; + timeEntry: { findFirst: jest.Mock; create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; + }; +}; + +describe('PUT /api/timer/:id', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('completes an existing running entry', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.timeEntry.findUnique.mockResolvedValue({ id: 'entry1', userId: 'user1' }); + mockPrisma.timeEntry.update.mockResolvedValue({ id: 'entry1', endTime: new Date().toISOString(), status: 'COMPLETED' }); + + const req = new Request('http://localhost/api/timer/entry1', { + method: 'PUT', + body: JSON.stringify({ endTime: new Date().toISOString() }), + }); + + const res = await PUT(req as Request, { params: Promise.resolve({ id: 'entry1' }) } as { params: Promise<{ id: string }> }); + const json = await res.json(); + + expect(json.timeEntry).toBeDefined(); + expect(json.timeEntry.status).toBe('COMPLETED'); + }); + + it('returns 404 for non-owner or missing entry', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { email: 'test@example.com' } }); + mockPrisma.user.upsert.mockResolvedValue({ id: 'user1', email: 'test@example.com' }); + mockPrisma.timeEntry.findUnique.mockResolvedValue(null); + + const req = new Request('http://localhost/api/timer/doesnotexist', { + method: 'PUT', + body: JSON.stringify({ endTime: new Date().toISOString() }), + }); + + const res = await PUT(req as Request, { params: Promise.resolve({ id: 'doesnotexist' }) } as { params: Promise<{ id: string }> }); + const json = await res.json(); + + expect(json.error).toBe('Time entry not found'); + }); +}); diff --git a/app/api/timer/[id]/route.ts b/app/api/timer/[id]/route.ts index c675129..9d99a3c 100644 --- a/app/api/timer/[id]/route.ts +++ b/app/api/timer/[id]/route.ts @@ -1,6 +1,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/auth'; import { prisma } from '@/lib/prisma'; +import { TimeEntryStatus } from '@prisma/client'; import { validateEnv } from '@/lib/env'; export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { @@ -29,13 +30,16 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri }, }); + const existing = await prisma.timeEntry.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return Response.json({ error: 'Time entry not found' }, { status: 404 }); + } + const updatedEntry = await prisma.timeEntry.update({ - where: { - id, - userId: user.id, - }, + where: { id }, data: { endTime: new Date(endTime), + status: TimeEntryStatus.COMPLETED, }, }); return Response.json({ timeEntry: updatedEntry }); diff --git a/app/api/timer/route.ts b/app/api/timer/route.ts index 6dc2c37..cf580f3 100644 --- a/app/api/timer/route.ts +++ b/app/api/timer/route.ts @@ -1,6 +1,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/auth'; import { prisma } from '@/lib/prisma'; +import { TimeEntryStatus } from '@prisma/client'; import { validateEnv } from '@/lib/env'; export async function POST(req: Request) { @@ -40,13 +41,23 @@ export async function POST(req: Request) { return Response.json({ error: 'Client not found' }, { status: 404 }); } - // Create time entry + // Ensure there is no other running entry for this user + const existingRunning = await prisma.timeEntry.findFirst({ + where: { userId: user.id, status: TimeEntryStatus.RUNNING }, + }); + + if (existingRunning) { + return Response.json({ error: 'A timer is already running' }, { status: 409 }); + } + + // Create time entry with status RUNNING const timeEntry = await prisma.timeEntry.create({ data: { startTime: new Date(startTime), endTime: endTime ? new Date(endTime) : null, clientId, userId: user.id, + status: TimeEntryStatus.RUNNING, }, }); @@ -75,16 +86,16 @@ export async function GET() { }, }); - const inProgressEntries = await prisma.timeEntry.findMany({ + const runningEntry = await prisma.timeEntry.findFirst({ where: { userId: user.id, - endTime: null, + status: 'RUNNING', }, include: { client: true, }, }); - return Response.json({ inProgressEntries }); + return Response.json({ runningEntry }); } catch (error) { console.error('Error fetching in-progress time entries:', error); return Response.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/app/timer/page.tsx b/app/timer/page.tsx index ce5f944..cefcbd2 100644 --- a/app/timer/page.tsx +++ b/app/timer/page.tsx @@ -31,6 +31,33 @@ export default function TimerPage() { }, }); + // On mount, check for any running entry and resume timer UI + useEffect(() => { + let mounted = true; + (async () => { + try { + const res = await fetch('/api/timer'); + if (!res.ok) return; + const data = await res.json(); + const entry = data.runningEntry; + if (mounted && entry) { + setSelectedClientId(entry.clientId); + setActiveEntryId(entry.id); + const s = new Date(entry.startTime); + setStartTime(s); + const diff = Math.floor((Date.now() - s.getTime()) / 1000); + setSeconds(Math.max(0, diff)); + setIsRunning(true); + } + } catch { + // ignore + } + })(); + return () => { + mounted = false; + }; + }, []); + useEffect(() => { let interval: NodeJS.Timeout; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..82c51a5 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,27 @@ +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'jsdom', + testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: { + jsx: 'react-jsx', + paths: {} // disable tsconfig path mapping in ts-jest + } + }], + }, + moduleNameMapper: { + '^@/auth$': '/auth.ts', // specific override first + '^@/(.*)$': '/app/$1', // matches tsconfig: @/* -> ./app/* + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + setupFilesAfterEnv: ['/jest.setup.ts'], + collectCoverageFrom: [ + 'app/**/*.{ts,tsx}', + 'components/**/*.{ts,tsx}', + '!**/node_modules/**', + '!**/.next/**', + ], +}; + +module.exports = config; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index a95f358..0000000 --- a/jest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Config } from 'jest'; - -const config: Config = { - testEnvironment: 'jsdom', - testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], - transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - setupFilesAfterEnv: ['/jest.setup.ts'], - collectCoverageFrom: [ - 'app/**/*.{ts,tsx}', - 'components/**/*.{ts,tsx}', - '!**/node_modules/**', - '!**/.next/**', - ], -}; - -export default config; diff --git a/jest.setup.ts b/jest.setup.ts index 0ceaa48..cd6e75e 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,64 @@ import '@testing-library/jest-dom'; import { TextEncoder, TextDecoder } from 'util'; -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; + +// Ensure TextEncoder/TextDecoder exist before any runtime shims +(global as unknown as { TextEncoder: typeof TextEncoder }).TextEncoder = TextEncoder; +(global as unknown as { TextDecoder: typeof TextDecoder }).TextDecoder = TextDecoder; + +// Minimal Request polyfill for Node/Jest environment used by API route tests +// The real Request has more behavior; this provides `json()` and stores body/method. +class TestRequest { + url: string; + method?: string; + headers?: Record; + private _body?: string | undefined; + constructor(url: string, init?: { method?: string; headers?: Record; body?: string | undefined }) { + this.url = url; + this.method = init?.method; + this.headers = init?.headers; + this._body = init?.body; + } + async json() { + if (this._body === undefined) return null; + return JSON.parse(this._body); + } + text() { + return Promise.resolve(this._body ?? ''); + } +} + +(global as unknown as { Request?: unknown }).Request = TestRequest; + +// Basic env required by validateEnv and next-auth during tests +process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test'; +process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret'; +process.env.NEXTAUTH_URL = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + +// Minimal Response.json helper used by Next.js app route handlers in tests +// Provide a minimal Response-like object used by app route handlers in tests +class TestResponse { + body: unknown; + status?: number; + ok: boolean; + constructor(body: unknown, init?: { status?: number }) { + this.body = body; + this.status = init?.status; + this.ok = this.status === undefined ? true : this.status >= 200 && this.status < 300; + } + async json() { + return this.body; + } +} + +if ((global as unknown as { Response?: unknown }).Response === undefined) { + (global as unknown as { Response?: unknown }).Response = { + json(body: unknown, init?: { status?: number }) { + return new TestResponse(body, init); + }, + }; +} + +// Only assign TestRequest if a global Request is not already present +if ((global as unknown as { Request?: unknown }).Request === undefined) { + (global as unknown as { Request?: unknown }).Request = TestRequest; +} \ No newline at end of file diff --git a/prisma/migrations/20260403085905_add_timeentry_status/migration.sql b/prisma/migrations/20260403085905_add_timeentry_status/migration.sql new file mode 100644 index 0000000..1d690c4 --- /dev/null +++ b/prisma/migrations/20260403085905_add_timeentry_status/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "TimeEntryStatus" AS ENUM ('RUNNING', 'COMPLETED'); + +-- AlterTable +ALTER TABLE "TimeEntry" ADD COLUMN "status" "TimeEntryStatus" NOT NULL DEFAULT 'RUNNING'; +-- Backfill existing entries: if an entry already has an endTime, mark it COMPLETED +UPDATE "TimeEntry" +SET "status" = 'COMPLETED' +WHERE "endTime" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 247b6ee..a2983bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model TimeEntry { id String @id @default(cuid()) startTime DateTime endTime DateTime? + status TimeEntryStatus @default(RUNNING) createdAt DateTime @default(now()) clientId String @@ -51,6 +52,11 @@ model TimeEntry { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +enum TimeEntryStatus { + RUNNING + COMPLETED +} + model Invoice { id String @id @default(cuid()) // Human-readable invoice number, e.g. INV-001 (scoped per user)