Added e2e tests for filewatcher
Some checks failed
Docker Image CI / build (push) Has been cancelled
Some checks failed
Docker Image CI / build (push) Has been cancelled
This commit is contained in:
227
tests/utils/mock-services.ts
Normal file
227
tests/utils/mock-services.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Mock services for file watcher e2e tests
|
||||
* Provides mock implementations of Moleculer services
|
||||
*/
|
||||
import { ServiceBroker, Context, ServiceSchema } from "moleculer";
|
||||
import { EventCapturer } from "./test-helpers";
|
||||
|
||||
/**
|
||||
* Mock call tracking interface
|
||||
*/
|
||||
export interface MockCall {
|
||||
action: string;
|
||||
params: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock broker wrapper that tracks all calls and events
|
||||
*/
|
||||
export class MockBrokerWrapper {
|
||||
public broker: ServiceBroker;
|
||||
public calls: MockCall[] = [];
|
||||
public eventCapturer: EventCapturer;
|
||||
private mockResponses: Map<string, any> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.eventCapturer = new EventCapturer();
|
||||
this.broker = new ServiceBroker({
|
||||
logger: false, // Suppress logs during tests
|
||||
transporter: null, // No actual transport needed
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures a mock response for a specific action
|
||||
*/
|
||||
mockResponse(action: string, response: any): void {
|
||||
this.mockResponses.set(action, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all calls made to a specific action
|
||||
*/
|
||||
getCallsTo(action: string): MockCall[] {
|
||||
return this.calls.filter((c) => c.action === action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an action was called
|
||||
*/
|
||||
wasCalled(action: string): boolean {
|
||||
return this.calls.some((c) => c.action === action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all recorded calls
|
||||
*/
|
||||
clearCalls(): void {
|
||||
this.calls = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the broker
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.broker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the broker
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.broker.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock socket service that captures broadcast events
|
||||
*/
|
||||
export function createMockSocketService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||
return {
|
||||
name: "socket",
|
||||
actions: {
|
||||
broadcast(ctx: Context<{ namespace: string; event: string; args: any[] }>) {
|
||||
const { event, args } = ctx.params;
|
||||
wrapper.calls.push({
|
||||
action: "socket.broadcast",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
wrapper.eventCapturer.capture(event, ...args);
|
||||
return { success: true };
|
||||
},
|
||||
broadcastLibraryStatistics(ctx: Context<{ directoryPath?: string }>) {
|
||||
wrapper.calls.push({
|
||||
action: "socket.broadcastLibraryStatistics",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock library service that tracks database operations
|
||||
*/
|
||||
export function createMockLibraryService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||
return {
|
||||
name: "library",
|
||||
actions: {
|
||||
markFileAsMissing(ctx: Context<{ filePath: string }>) {
|
||||
const { filePath } = ctx.params;
|
||||
wrapper.calls.push({
|
||||
action: "library.markFileAsMissing",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Return a mock response simulating comics being marked as missing
|
||||
const mockResult = {
|
||||
marked: 1,
|
||||
missingComics: [
|
||||
{
|
||||
_id: "mock-id-123",
|
||||
rawFileDetails: {
|
||||
name: "Test Comic",
|
||||
filePath,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return mockResult;
|
||||
},
|
||||
clearFileMissingFlag(ctx: Context<{ filePath: string }>) {
|
||||
wrapper.calls.push({
|
||||
action: "library.clearFileMissingFlag",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
getImportStatistics(ctx: Context<{ directoryPath?: string }>) {
|
||||
wrapper.calls.push({
|
||||
action: "library.getImportStatistics",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
directory: ctx.params.directoryPath || "/comics",
|
||||
stats: {
|
||||
totalLocalFiles: 10,
|
||||
alreadyImported: 5,
|
||||
newFiles: 5,
|
||||
missingFiles: 0,
|
||||
percentageImported: "50.00%",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock importstate service
|
||||
*/
|
||||
export function createMockImportStateService(wrapper: MockBrokerWrapper): ServiceSchema {
|
||||
let watcherEnabled = true;
|
||||
|
||||
return {
|
||||
name: "importstate",
|
||||
actions: {
|
||||
isWatcherEnabled() {
|
||||
wrapper.calls.push({
|
||||
action: "importstate.isWatcherEnabled",
|
||||
params: {},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { enabled: watcherEnabled };
|
||||
},
|
||||
startSession(ctx: Context<{ sessionId: string; type: string; directoryPath?: string }>) {
|
||||
wrapper.calls.push({
|
||||
action: "importstate.startSession",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (ctx.params.type !== "watcher") {
|
||||
watcherEnabled = false;
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
completeSession(ctx: Context<{ sessionId: string; success: boolean }>) {
|
||||
wrapper.calls.push({
|
||||
action: "importstate.completeSession",
|
||||
params: ctx.params,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
watcherEnabled = true;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a complete mock broker with all services registered
|
||||
*/
|
||||
export async function setupMockBroker(): Promise<MockBrokerWrapper> {
|
||||
const wrapper = new MockBrokerWrapper();
|
||||
|
||||
// Create and register mock services
|
||||
wrapper.broker.createService(createMockSocketService(wrapper));
|
||||
wrapper.broker.createService(createMockLibraryService(wrapper));
|
||||
wrapper.broker.createService(createMockImportStateService(wrapper));
|
||||
|
||||
await wrapper.start();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the mock broker
|
||||
*/
|
||||
export async function teardownMockBroker(wrapper: MockBrokerWrapper): Promise<void> {
|
||||
await wrapper.stop();
|
||||
}
|
||||
267
tests/utils/test-helpers.ts
Normal file
267
tests/utils/test-helpers.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Test helper utilities for file watcher e2e tests
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fsExtra from "fs-extra";
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
/**
|
||||
* Event capture interface for tracking emitted events
|
||||
*/
|
||||
export interface CapturedEvent {
|
||||
event: string;
|
||||
args: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary directory for testing
|
||||
* @returns Path to the created temp directory
|
||||
*/
|
||||
export async function createTempDir(prefix: string = "threetwo-test-"): Promise<string> {
|
||||
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a temporary directory and all its contents
|
||||
* @param dirPath Path to the directory to remove
|
||||
*/
|
||||
export async function removeTempDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fsExtra.remove(dirPath);
|
||||
} catch (error) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock comic file with the specified extension
|
||||
* @param dirPath Directory to create the file in
|
||||
* @param fileName Name of the file (without extension)
|
||||
* @param extension File extension (.cbz, .cbr, .cb7)
|
||||
* @param sizeKB Size of the file in KB (default 10KB)
|
||||
* @returns Full path to the created file
|
||||
*/
|
||||
export async function createMockComicFile(
|
||||
dirPath: string,
|
||||
fileName: string,
|
||||
extension: ".cbz" | ".cbr" | ".cb7" = ".cbz",
|
||||
sizeKB: number = 10
|
||||
): Promise<string> {
|
||||
const filePath = path.join(dirPath, `${fileName}${extension}`);
|
||||
// Create a file with random content of specified size
|
||||
const buffer = Buffer.alloc(sizeKB * 1024);
|
||||
// Add a minimal ZIP header for .cbz files to make them somewhat valid
|
||||
if (extension === ".cbz") {
|
||||
buffer.write("PK\x03\x04", 0); // ZIP local file header signature
|
||||
}
|
||||
await fsp.writeFile(filePath, buffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a non-comic file (for testing filtering)
|
||||
* @param dirPath Directory to create the file in
|
||||
* @param fileName Full filename including extension
|
||||
* @param content File content
|
||||
* @returns Full path to the created file
|
||||
*/
|
||||
export async function createNonComicFile(
|
||||
dirPath: string,
|
||||
fileName: string,
|
||||
content: string = "test content"
|
||||
): Promise<string> {
|
||||
const filePath = path.join(dirPath, fileName);
|
||||
await fsp.writeFile(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subdirectory
|
||||
* @param parentDir Parent directory path
|
||||
* @param subDirName Name of the subdirectory
|
||||
* @returns Full path to the created subdirectory
|
||||
*/
|
||||
export async function createSubDir(parentDir: string, subDirName: string): Promise<string> {
|
||||
const subDirPath = path.join(parentDir, subDirName);
|
||||
await fsp.mkdir(subDirPath, { recursive: true });
|
||||
return subDirPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file
|
||||
* @param filePath Path to the file to delete
|
||||
*/
|
||||
export async function deleteFile(filePath: string): Promise<void> {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a directory and all its contents
|
||||
* @param dirPath Path to the directory to delete
|
||||
*/
|
||||
export async function deleteDir(dirPath: string): Promise<void> {
|
||||
await fsExtra.remove(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specific duration
|
||||
* @param ms Milliseconds to wait
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a condition to be true, with timeout
|
||||
* @param condition Function that returns true when condition is met
|
||||
* @param timeoutMs Maximum time to wait in milliseconds
|
||||
* @param intervalMs Check interval in milliseconds
|
||||
* @returns True if condition was met, false if timed out
|
||||
*/
|
||||
export async function waitForCondition(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number = 10000,
|
||||
intervalMs: number = 100
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await condition()) {
|
||||
return true;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event capturer that records all emitted events
|
||||
*/
|
||||
export class EventCapturer {
|
||||
private events: CapturedEvent[] = [];
|
||||
|
||||
/**
|
||||
* Records an event
|
||||
*/
|
||||
capture(event: string, ...args: any[]): void {
|
||||
this.events.push({
|
||||
event,
|
||||
args,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all captured events
|
||||
*/
|
||||
getAll(): CapturedEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns events matching the given event name
|
||||
*/
|
||||
getByEvent(eventName: string): CapturedEvent[] {
|
||||
return this.events.filter((e) => e.event === eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific event was captured
|
||||
*/
|
||||
hasEvent(eventName: string): boolean {
|
||||
return this.events.some((e) => e.event === eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specific event to be captured
|
||||
*/
|
||||
async waitForEvent(eventName: string, timeoutMs: number = 10000): Promise<CapturedEvent | null> {
|
||||
const result = await waitForCondition(() => this.hasEvent(eventName), timeoutMs);
|
||||
if (result) {
|
||||
return this.getByEvent(eventName)[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all captured events
|
||||
*/
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of captured events
|
||||
*/
|
||||
get count(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock file stats object
|
||||
*/
|
||||
export function createMockStats(options: Partial<fs.Stats> = {}): fs.Stats {
|
||||
const now = new Date();
|
||||
return {
|
||||
dev: 0,
|
||||
ino: 0,
|
||||
mode: 0o100644,
|
||||
nlink: 1,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
rdev: 0,
|
||||
size: options.size ?? 10240,
|
||||
blksize: 4096,
|
||||
blocks: 8,
|
||||
atimeMs: now.getTime(),
|
||||
mtimeMs: options.mtimeMs ?? now.getTime(),
|
||||
ctimeMs: now.getTime(),
|
||||
birthtimeMs: now.getTime(),
|
||||
atime: now,
|
||||
mtime: options.mtime ?? now,
|
||||
ctime: now,
|
||||
birthtime: now,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
} as fs.Stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a file (simulates a real file transfer)
|
||||
* @param sourcePath Source file path
|
||||
* @param destPath Destination file path
|
||||
*/
|
||||
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
|
||||
await fsp.copyFile(sourcePath, destPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a file to a new location
|
||||
* @param sourcePath Source file path
|
||||
* @param destPath Destination file path
|
||||
*/
|
||||
export async function moveFile(sourcePath: string, destPath: string): Promise<void> {
|
||||
await fsp.rename(sourcePath, destPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Touches a file (updates its mtime)
|
||||
* @param filePath Path to the file
|
||||
*/
|
||||
export async function touchFile(filePath: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await fsp.utimes(filePath, now, now);
|
||||
}
|
||||
Reference in New Issue
Block a user