Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { Queue, Job } from 'bullmq';
|
||||
|
||||
/**
|
||||
* In-memory queue implementation for testing
|
||||
* Provides realistic queue behavior without Redis
|
||||
*/
|
||||
export class MockQueueStore<T = any> {
|
||||
private jobs = new Map<string, MockJobData<T>>();
|
||||
private jobCounter = 0;
|
||||
|
||||
async add(name: string | T, data?: T | any, opts?: any): Promise<MockJobData<T>> {
|
||||
const jobName = typeof name === 'string' ? name : undefined;
|
||||
const jobData = typeof name === 'string' ? data : name;
|
||||
const jobOpts = typeof name === 'string' ? opts : data;
|
||||
|
||||
const jobId = jobOpts?.jobId || `job-${++this.jobCounter}`;
|
||||
|
||||
const job: MockJobData<T> = {
|
||||
id: jobId,
|
||||
name: jobName,
|
||||
data: jobData,
|
||||
opts: jobOpts,
|
||||
state: 'waiting',
|
||||
attemptsMade: 0,
|
||||
timestamp: Date.now(),
|
||||
processedOn: undefined,
|
||||
finishedOn: undefined,
|
||||
returnvalue: undefined,
|
||||
failedReason: undefined,
|
||||
};
|
||||
|
||||
this.jobs.set(jobId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<MockJobData<T> | null> {
|
||||
return this.jobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
async getJobs(states: string[]): Promise<MockJobData<T>[]> {
|
||||
return Array.from(this.jobs.values()).filter((job) => states.includes(job.state));
|
||||
}
|
||||
|
||||
async getJobCounts(...states: string[]): Promise<Record<string, number>> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const state of states) {
|
||||
counts[state] = Array.from(this.jobs.values()).filter((j) => j.state === state).length;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async removeJob(jobId: string): Promise<void> {
|
||||
this.jobs.delete(jobId);
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async clean(grace: number, limit: number, type: string): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const [jobId, job] of this.jobs.entries()) {
|
||||
if (job.state === type && job.finishedOn && now - job.finishedOn > grace) {
|
||||
this.jobs.delete(jobId);
|
||||
removed.push(jobId);
|
||||
if (removed.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.jobs.clear();
|
||||
}
|
||||
|
||||
// Test utilities
|
||||
getAllJobs(): MockJobData<T>[] {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
getJobsByName(name: string): MockJobData<T>[] {
|
||||
return Array.from(this.jobs.values()).filter((job) => job.name === name);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.jobs.clear();
|
||||
this.jobCounter = 0;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.jobs.size;
|
||||
}
|
||||
|
||||
markJobAsCompleted(jobId: string, returnvalue?: any): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.state = 'completed';
|
||||
job.finishedOn = Date.now();
|
||||
job.returnvalue = returnvalue;
|
||||
}
|
||||
}
|
||||
|
||||
markJobAsFailed(jobId: string, error: string): void {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.state = 'failed';
|
||||
job.finishedOn = Date.now();
|
||||
job.failedReason = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MockJobData<T = any> {
|
||||
id: string;
|
||||
name?: string;
|
||||
data: T;
|
||||
opts?: any;
|
||||
state: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
|
||||
attemptsMade: number;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
returnvalue?: any;
|
||||
failedReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock BullMQ queues for testing
|
||||
*/
|
||||
export function createMockQueues() {
|
||||
return {
|
||||
email: new MockQueueStore(),
|
||||
campaign: new MockQueueStore(),
|
||||
workflow: new MockQueueStore(),
|
||||
scheduled: new MockQueueStore(),
|
||||
import: new MockQueueStore(),
|
||||
segmentCount: new MockQueueStore(),
|
||||
domainVerification: new MockQueueStore(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a typed mock queue using vitest-mock-extended
|
||||
*/
|
||||
export function createMockQueue<T = any>(): DeepMockProxy<Queue<T>> {
|
||||
const mock = mockDeep<Queue<T>>();
|
||||
|
||||
// Default implementations
|
||||
mock.add.mockImplementation(async (name, data, opts) => {
|
||||
return {
|
||||
id: opts?.jobId || `mock-job-${Date.now()}`,
|
||||
data: (typeof name === 'string' ? data : name) as T,
|
||||
attemptsMade: 0,
|
||||
} as unknown as Job<T>;
|
||||
});
|
||||
|
||||
mock.getJob.mockResolvedValue(null);
|
||||
mock.getJobs.mockResolvedValue([]);
|
||||
mock.getJobCounts.mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
});
|
||||
mock.pause.mockResolvedValue(undefined);
|
||||
mock.resume.mockResolvedValue(undefined);
|
||||
mock.close.mockResolvedValue(undefined);
|
||||
|
||||
return mock;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockDeep, mockReset, type DeepMockProxy } from 'vitest-mock-extended';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
/**
|
||||
* Mock Redis instance for testing
|
||||
* Uses in-memory Map to simulate Redis operations
|
||||
*/
|
||||
export class MockRedis {
|
||||
private store = new Map<string, { value: string; expiry?: number }>();
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
const item = this.store.get(key);
|
||||
if (!item) return Promise.resolve(null);
|
||||
|
||||
// Check expiry
|
||||
if (item.expiry && Date.now() > item.expiry) {
|
||||
this.store.delete(key);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.resolve(item.value);
|
||||
}
|
||||
|
||||
set(key: string, value: string): Promise<'OK'> {
|
||||
this.store.set(key, { value });
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
setex(key: string, seconds: number, value: string): Promise<'OK'> {
|
||||
const expiry = Date.now() + seconds * 1000;
|
||||
this.store.set(key, { value, expiry });
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
del(...keys: string[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (this.store.delete(key)) count++;
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
incr(key: string): Promise<number> {
|
||||
const item = this.store.get(key);
|
||||
const current = item ? parseInt(item.value, 10) : 0;
|
||||
const newValue = current + 1;
|
||||
this.store.set(key, { value: String(newValue) });
|
||||
return Promise.resolve(newValue);
|
||||
}
|
||||
|
||||
expire(key: string, seconds: number): Promise<number> {
|
||||
const item = this.store.get(key);
|
||||
if (!item) return Promise.resolve(0);
|
||||
|
||||
const expiry = Date.now() + seconds * 1000;
|
||||
this.store.set(key, { ...item, expiry });
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
exists(...keys: string[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (this.store.has(key)) count++;
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
keys(pattern: string): Promise<string[]> {
|
||||
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
||||
const matchingKeys = Array.from(this.store.keys()).filter((key) => regex.test(key));
|
||||
return Promise.resolve(matchingKeys);
|
||||
}
|
||||
|
||||
flushall(): Promise<'OK'> {
|
||||
this.store.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
flushdb(): Promise<'OK'> {
|
||||
this.store.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored data (for test cleanup)
|
||||
*/
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys (for debugging)
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
return Array.from(this.store.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store size (for assertions)
|
||||
*/
|
||||
size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Redis instance using vitest-mock-extended
|
||||
* This provides full type safety and spy functionality
|
||||
*/
|
||||
export function createMockRedis(): DeepMockProxy<Redis> {
|
||||
const mock = mockDeep<Redis>();
|
||||
|
||||
// Default implementations
|
||||
mock.get.mockResolvedValue(null);
|
||||
mock.set.mockResolvedValue('OK');
|
||||
mock.setex.mockResolvedValue('OK');
|
||||
mock.del.mockResolvedValue(1);
|
||||
mock.incr.mockResolvedValue(1);
|
||||
mock.expire.mockResolvedValue(1);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a functional mock Redis with in-memory storage
|
||||
* Use this when you need Redis behavior (caching, expiry, etc.)
|
||||
*/
|
||||
export function createFunctionalMockRedis(): MockRedis {
|
||||
return new MockRedis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a mock Redis instance
|
||||
*/
|
||||
export function resetMockRedis(redis: DeepMockProxy<Redis>): void {
|
||||
mockReset(redis);
|
||||
|
||||
// Restore default implementations
|
||||
redis.get.mockResolvedValue(null);
|
||||
redis.set.mockResolvedValue('OK');
|
||||
redis.setex.mockResolvedValue('OK');
|
||||
redis.del.mockResolvedValue(1);
|
||||
redis.incr.mockResolvedValue(1);
|
||||
redis.expire.mockResolvedValue(1);
|
||||
}
|
||||
Reference in New Issue
Block a user