/** * Unit tests for scan-projects.ts */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ProjectScanner, ProjectInfo } from '../scan-projects' import { z } from 'zod' // Mock dependencies vi.mock('fs/promises') vi.mock('fs') vi.mock('child_process') vi.mock('cli-progress') vi.mock('dotenv') describe('ProjectScanner', () => { const mockOptions = { projectsDir: '/test/projects', dryRun: false, skipExisting: false, apiUrl: 'http://localhost:4000/graphql', email: 'test@example.com', password: 'password123', outputFormat: 'text' as const, verbose: false, batchSize: 1, } describe('validateProjectName', () => { it('should validate and sanitize valid project names', () => { const scanner = new ProjectScanner(mockOptions) const testCases = [ { input: 'my-project', expected: 'my-project' }, { input: 'my_project', expected: 'my_project' }, { input: 'myProject123', expected: 'myProject123' }, { input: 'my-project_123', expected: 'my-project_123' }, ] testCases.forEach(({ input, expected }) => { const result = (scanner as any).validateProjectName(input) expect(result).toBe(expected) }) }) it('should sanitize invalid characters', () => { const scanner = new ProjectScanner(mockOptions) const testCases = [ { input: 'my project', expected: 'my-project' }, { input: 'my@project', expected: 'my-project' }, { input: 'my.project', expected: 'my-project' }, { input: 'my---project', expected: 'my-project' }, { input: '-my-project-', expected: 'my-project' }, ] testCases.forEach(({ input, expected }) => { const result = (scanner as any).validateProjectName(input) expect(result).toBe(expected) }) }) it('should truncate long names', () => { const scanner = new ProjectScanner(mockOptions) const longName = 'a'.repeat(300) const result = (scanner as any).validateProjectName(longName) expect(result.length).toBeLessThanOrEqual(255) }) it('should handle empty names', () => { const scanner = new ProjectScanner(mockOptions) const result = (scanner as any).validateProjectName('') expect(result).toBe('project') }) }) describe('sanitizeMetadata', () => { it('should sanitize metadata and remove invalid types', () => { const scanner = new ProjectScanner(mockOptions) const metadata = { string: 'value', number: 123, boolean: true, null: null, array: [1, 2, 3], object: { nested: 'value' }, function: () => {}, // Should be removed undefined: undefined, // Should be removed } const result = (scanner as any).sanitizeMetadata(metadata) expect(result.string).toBe('value') expect(result.number).toBe(123) expect(result.boolean).toBe(true) expect(result.null).toBeNull() expect(Array.isArray(result.array)).toBe(true) expect(typeof result.object).toBe('object') expect(result.function).toBeUndefined() expect(result.undefined).toBeUndefined() }) it('should enforce size limits', () => { const scanner = new ProjectScanner(mockOptions) const largeMetadata = { description: 'x'.repeat(15000), // Too large version: '1.0.0', } expect(() => { (scanner as any).sanitizeMetadata(largeMetadata) }).toThrow('Metadata too large') }) }) describe('extractDomainFromGitRemote', () => { it('should extract domain from HTTPS URLs', () => { const scanner = new ProjectScanner(mockOptions) const testCases = [ { input: 'https://github.com/user/repo.git', expected: 'github-com.dev', }, { input: 'https://gitlab.com/user/repo.git', expected: 'gitlab-com.dev', }, { input: 'https://bitbucket.org/user/repo.git', expected: 'bitbucket-org.dev', }, ] testCases.forEach(({ input, expected }) => { const result = (scanner as any).extractDomainFromGitRemote(input) expect(result).toBe(expected) }) }) it('should extract domain from SSH URLs', () => { const scanner = new ProjectScanner(mockOptions) const testCases = [ { input: 'git@github.com:user/repo.git', expected: 'github-com.dev', }, { input: 'ssh://git@gitlab.com:user/repo.git', expected: 'gitlab-com.dev', }, ] testCases.forEach(({ input, expected }) => { const result = (scanner as any).extractDomainFromGitRemote(input) expect(result).toBe(expected) }) }) it('should return undefined for invalid URLs', () => { const scanner = new ProjectScanner(mockOptions) const result = (scanner as any).extractDomainFromGitRemote('invalid-url') expect(result).toBeUndefined() }) it('should return undefined for empty input', () => { const scanner = new ProjectScanner(mockOptions) const result = (scanner as any).extractDomainFromGitRemote(undefined) expect(result).toBeUndefined() }) }) describe('detectPackageManager', () => { it('should detect pnpm from pnpm-lock.yaml', () => { const scanner = new ProjectScanner(mockOptions) const { existsSync } = require('fs') vi.mocked(existsSync).mockImplementation((path: string) => { return path.includes('pnpm-lock.yaml') }) const result = (scanner as any).detectPackageManager('/test/project') expect(result).toBe('pnpm') }) it('should detect yarn from yarn.lock', () => { const scanner = new ProjectScanner(mockOptions) const { existsSync } = require('fs') vi.mocked(existsSync).mockImplementation((path: string) => { return path.includes('yarn.lock') }) const result = (scanner as any).detectPackageManager('/test/project') expect(result).toBe('yarn') }) it('should detect npm from package-lock.json', () => { const scanner = new ProjectScanner(mockOptions) const { existsSync } = require('fs') vi.mocked(existsSync).mockImplementation((path: string) => { return path.includes('package-lock.json') }) const result = (scanner as any).detectPackageManager('/test/project') expect(result).toBe('npm') }) it('should return unknown if no lock file found', () => { const scanner = new ProjectScanner(mockOptions) const { existsSync } = require('fs') vi.mocked(existsSync).mockReturnValue(false) const result = (scanner as any).detectPackageManager('/test/project') expect(result).toBe('unknown') }) }) describe('isDuplicate', () => { it('should detect duplicates by name', () => { const scanner = new ProjectScanner(mockOptions) ;(scanner as any).existingTenants = new Map([ ['my-project', { id: '123', name: 'my-project' }], ]) const project: ProjectInfo = { name: 'my-project', path: '/test', type: 'repo', metadata: {}, } const result = (scanner as any).isDuplicate(project) expect(result).not.toBeNull() expect(result.name).toBe('my-project') }) it('should detect duplicates by domain', () => { const scanner = new ProjectScanner(mockOptions) ;(scanner as any).existingTenants = new Map([ ['github-com.dev', { id: '123', name: 'existing', domain: 'github-com.dev' }], ]) const project: ProjectInfo = { name: 'new-project', path: '/test', type: 'repo', gitRemote: 'https://github.com/user/repo.git', metadata: {}, } const result = (scanner as any).isDuplicate(project) expect(result).not.toBeNull() }) it('should return null for non-duplicates', () => { const scanner = new ProjectScanner(mockOptions) ;(scanner as any).existingTenants = new Map() const project: ProjectInfo = { name: 'new-project', path: '/test', type: 'repo', metadata: {}, } const result = (scanner as any).isDuplicate(project) expect(result).toBeNull() }) }) }) describe('Validation Schemas', () => { describe('projectNameSchema', () => { it('should accept valid project names', () => { const validNames = ['my-project', 'my_project', 'myProject123', 'my-project_123'] validNames.forEach((name) => { expect(() => { z.string().min(1).max(255).regex(/^[a-zA-Z0-9_-]+$/).parse(name) }).not.toThrow() }) }) it('should reject invalid project names', () => { const invalidNames = ['my project', 'my@project', 'my.project', 'my-project!'] invalidNames.forEach((name) => { expect(() => { z.string().min(1).max(255).regex(/^[a-zA-Z0-9_-]+$/).parse(name) }).toThrow() }) }) }) describe('gitUrlSchema', () => { it('should accept valid Git URLs', () => { const validUrls = [ 'https://github.com/user/repo.git', 'git@github.com:user/repo.git', 'ssh://git@gitlab.com:user/repo.git', ] validUrls.forEach((url) => { expect(() => { z.string().url().parse(url) }).not.toThrow() }) }) it('should reject invalid URLs', () => { const invalidUrls = ['not-a-url', 'http://', 'git@'] invalidUrls.forEach((url) => { expect(() => { z.string().url().parse(url) }).toThrow() }) }) }) })