Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+180
View File
@@ -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;
}
+146
View File
@@ -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);
}