380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
/**
|
|
* PublishEngine Unit Tests
|
|
*
|
|
* Tests the site upload engine that publishes generated site content
|
|
* via SCP or rsync to a remote server.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import path from 'path';
|
|
import { PublishEngine, type PublishCredentials, type PublishResult } from '../../src/main/engine/PublishEngine';
|
|
|
|
// Hoist mock variables so they're available inside vi.mock factories
|
|
const {
|
|
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
|
|
mockReaddir, mockFsStat, mockAccess,
|
|
mockRsync,
|
|
} = vi.hoisted(() => {
|
|
const mockStat = vi.fn();
|
|
const mockUploadFile = vi.fn();
|
|
const mockMkdir = vi.fn();
|
|
const mockClose = vi.fn();
|
|
const mockScpClient = {
|
|
stat: mockStat,
|
|
uploadFile: mockUploadFile,
|
|
mkdir: mockMkdir,
|
|
close: mockClose,
|
|
};
|
|
const mockReaddir = vi.fn();
|
|
const mockFsStat = vi.fn();
|
|
const mockAccess = vi.fn();
|
|
const mockRsync = vi.fn((_options: any, callback: any) => {
|
|
callback(null, '', '', 'rsync command');
|
|
});
|
|
return {
|
|
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
|
|
mockReaddir, mockFsStat, mockAccess,
|
|
mockRsync,
|
|
};
|
|
});
|
|
|
|
// Mock node-scp
|
|
vi.mock('node-scp', () => ({
|
|
Client: vi.fn().mockResolvedValue(mockScpClient),
|
|
default: vi.fn().mockResolvedValue(mockScpClient),
|
|
}));
|
|
|
|
// Mock rsyncwrapper
|
|
vi.mock('rsyncwrapper', () => ({
|
|
default: mockRsync,
|
|
}));
|
|
|
|
// Mock fs/promises
|
|
vi.mock('fs/promises', () => ({
|
|
default: {
|
|
readdir: (...args: any[]) => mockReaddir(...args),
|
|
stat: (...args: any[]) => mockFsStat(...args),
|
|
access: (...args: any[]) => mockAccess(...args),
|
|
},
|
|
readdir: (...args: any[]) => mockReaddir(...args),
|
|
stat: (...args: any[]) => mockFsStat(...args),
|
|
access: (...args: any[]) => mockAccess(...args),
|
|
}));
|
|
|
|
// Mock fs
|
|
vi.mock('fs', () => ({
|
|
default: { constants: { F_OK: 0 } },
|
|
constants: { F_OK: 0 },
|
|
}));
|
|
|
|
describe('PublishEngine', () => {
|
|
let engine: PublishEngine;
|
|
const dataDir = '/projects/test-project';
|
|
|
|
const defaultCredentials: PublishCredentials = {
|
|
sshHost: 'example.com',
|
|
sshUser: 'deploy',
|
|
sshRemotePath: '/var/www/html',
|
|
sshMode: 'scp',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
engine = new PublishEngine();
|
|
engine.setProjectContext('test-project', dataDir);
|
|
|
|
// Default: directories exist, files are regular files
|
|
mockAccess.mockResolvedValue(undefined);
|
|
mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() });
|
|
mockReaddir.mockResolvedValue([]);
|
|
mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload
|
|
mockUploadFile.mockResolvedValue(undefined);
|
|
mockMkdir.mockResolvedValue(undefined);
|
|
mockClose.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('constructor and project context', () => {
|
|
it('should be instantiated via getPublishEngine singleton', async () => {
|
|
const { getPublishEngine } = await import('../../src/main/engine/PublishEngine');
|
|
const e1 = getPublishEngine();
|
|
const e2 = getPublishEngine();
|
|
expect(e1).toBe(e2);
|
|
});
|
|
|
|
it('should throw if no project context is set', async () => {
|
|
const noContextEngine = new PublishEngine();
|
|
await expect(
|
|
noContextEngine.uploadSite(defaultCredentials, vi.fn()),
|
|
).rejects.toThrow('No project context');
|
|
});
|
|
});
|
|
|
|
describe('credential validation', () => {
|
|
it('should throw if sshHost is empty', async () => {
|
|
await expect(
|
|
engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()),
|
|
).rejects.toThrow('SSH host is required');
|
|
});
|
|
|
|
it('should throw if sshUser is empty', async () => {
|
|
await expect(
|
|
engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()),
|
|
).rejects.toThrow('SSH user is required');
|
|
});
|
|
|
|
it('should throw if sshRemotePath is empty', async () => {
|
|
await expect(
|
|
engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
|
|
).rejects.toThrow('Remote path is required');
|
|
});
|
|
});
|
|
|
|
describe('directory validation', () => {
|
|
it('should throw if html directory does not exist', async () => {
|
|
mockAccess.mockImplementation(async (p: string) => {
|
|
if ((p as string).endsWith('html')) throw new Error('ENOENT');
|
|
});
|
|
|
|
await expect(
|
|
engine.uploadSite(defaultCredentials, vi.fn()),
|
|
).rejects.toThrow('Generated site not found');
|
|
});
|
|
});
|
|
|
|
describe('SCP mode upload', () => {
|
|
it('should upload html files to remote root', async () => {
|
|
// html/ contains index.html
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: Date.now(),
|
|
});
|
|
|
|
const onProgress = vi.fn();
|
|
const result = await engine.uploadSite(defaultCredentials, onProgress);
|
|
|
|
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
expect(onProgress).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should upload thumbnail files to remote thumbnails/', async () => {
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [];
|
|
}
|
|
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
|
|
return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: Date.now(),
|
|
});
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should only upload image files from media, not .meta sidecars', async () => {
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [];
|
|
}
|
|
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
|
|
return [];
|
|
}
|
|
if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) {
|
|
return [
|
|
{ name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
|
|
{ name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true },
|
|
{ name: 'document.pdf', isDirectory: () => false, isFile: () => true },
|
|
{ name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true },
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: Date.now(),
|
|
});
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
// Should upload photo.jpg and document.pdf, but NOT .meta files
|
|
expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should skip files that are not newer than remote', async () => {
|
|
const remoteTime = Date.now() / 1000; // SSH stats use seconds
|
|
const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms)
|
|
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: localTimeOlder,
|
|
});
|
|
|
|
// Remote file exists and is newer
|
|
mockStat.mockResolvedValue({ mtime: remoteTime });
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
// File should be skipped since remote is newer
|
|
expect(mockUploadFile).not.toHaveBeenCalled();
|
|
expect(result.filesSkipped).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should upload files that are newer than remote', async () => {
|
|
const remoteTime = Date.now() / 1000 - 100; // Remote is 100s old
|
|
const localTimeNewer = Date.now(); // local is current (ms)
|
|
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: localTimeNewer,
|
|
});
|
|
|
|
mockStat.mockResolvedValue({ mtime: remoteTime });
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
expect(mockUploadFile).toHaveBeenCalled();
|
|
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should recurse into subdirectories', async () => {
|
|
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
|
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
|
return [
|
|
{ name: '2026', isDirectory: () => true, isFile: () => false },
|
|
];
|
|
}
|
|
if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) {
|
|
return [
|
|
{ name: 'post.html', isDirectory: () => false, isFile: () => true },
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
mockFsStat.mockResolvedValue({
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
mtimeMs: Date.now(),
|
|
});
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
expect(mockMkdir).toHaveBeenCalled(); // Should create remote subdir
|
|
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return a complete PublishResult', async () => {
|
|
mockReaddir.mockResolvedValue([]);
|
|
|
|
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
|
|
|
expect(result).toHaveProperty('htmlFilesUploaded');
|
|
expect(result).toHaveProperty('thumbnailFilesUploaded');
|
|
expect(result).toHaveProperty('mediaFilesUploaded');
|
|
expect(result).toHaveProperty('filesSkipped');
|
|
expect(typeof result.htmlFilesUploaded).toBe('number');
|
|
expect(typeof result.thumbnailFilesUploaded).toBe('number');
|
|
expect(typeof result.mediaFilesUploaded).toBe('number');
|
|
expect(typeof result.filesSkipped).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('rsync mode upload', () => {
|
|
const rsyncCredentials: PublishCredentials = {
|
|
...defaultCredentials,
|
|
sshMode: 'rsync',
|
|
};
|
|
|
|
it('should call rsync for html directory', async () => {
|
|
const rsync = (await import('rsyncwrapper')).default;
|
|
|
|
const result = await engine.uploadSite(rsyncCredentials, vi.fn());
|
|
|
|
expect(rsync).toHaveBeenCalled();
|
|
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should use --update and --times flags for incremental transfer', async () => {
|
|
const rsync = (await import('rsyncwrapper')).default;
|
|
|
|
await engine.uploadSite(rsyncCredentials, vi.fn());
|
|
|
|
// Check that rsync was called with update semantics
|
|
const calls = vi.mocked(rsync).mock.calls;
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
for (const [options] of calls) {
|
|
expect(options.args).toContain('--update');
|
|
expect(options.times).toBe(true);
|
|
expect(options.recursive).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should exclude .meta files when syncing media', async () => {
|
|
const rsync = (await import('rsyncwrapper')).default;
|
|
|
|
await engine.uploadSite(rsyncCredentials, vi.fn());
|
|
|
|
const calls = vi.mocked(rsync).mock.calls;
|
|
// Find the media sync call (dest contains /media)
|
|
const mediaCall = calls.find(([opts]) =>
|
|
typeof opts.dest === 'string' && opts.dest.includes('/media'),
|
|
);
|
|
if (mediaCall) {
|
|
expect(mediaCall[0].exclude).toContain('*.meta');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('progress reporting', () => {
|
|
it('should report progress through all three phases', async () => {
|
|
mockReaddir.mockResolvedValue([]);
|
|
|
|
const onProgress = vi.fn();
|
|
await engine.uploadSite(defaultCredentials, onProgress);
|
|
|
|
// Should have called onProgress at least once per phase
|
|
expect(onProgress).toHaveBeenCalled();
|
|
const progressValues = onProgress.mock.calls.map(([p]: [number]) => p);
|
|
// Should reach 100
|
|
expect(progressValues[progressValues.length - 1]).toBe(100);
|
|
});
|
|
});
|
|
});
|