Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions backend/src/__tests__/enrollmentService.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
enrollIdentity,
cancelEnrollment,
getEnrollmentsByIdentity,
getEnrollmentsByQueue,
} from '../services/enrollmentService.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { createEnrollmentService, type EnrollmentService } from '../services/enrollmentService.js';
import { MemoryAdapter } from '../storage/index.js';

// Issue #91: fresh injected store per test — no shared state, no resetModules.
let enrollIdentity: EnrollmentService['enrollIdentity'];
let cancelEnrollment: EnrollmentService['cancelEnrollment'];
let getEnrollmentsByIdentity: EnrollmentService['getEnrollmentsByIdentity'];
let getEnrollmentsByQueue: EnrollmentService['getEnrollmentsByQueue'];

beforeEach(() => {
vi.resetModules();
({ enrollIdentity, cancelEnrollment, getEnrollmentsByIdentity, getEnrollmentsByQueue } =
createEnrollmentService(new MemoryAdapter()));
});

describe('enrollIdentity', () => {
Expand Down
24 changes: 12 additions & 12 deletions backend/src/__tests__/escrowService.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
depositEscrow,
releaseEscrow,
refundEscrow,
expireEscrow,
getEscrow,
} from '../services/escrowService.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { createEscrowService, type EscrowService } from '../services/escrowService.js';
import { MemoryAdapter } from '../storage/index.js';

// Issue #91: each test gets a service over a fresh, injected store, so there is
// no shared module-level state to leak and no `vi.resetModules()` needed. Run
// order can never change a result.
let depositEscrow: EscrowService['depositEscrow'];
let releaseEscrow: EscrowService['releaseEscrow'];
let refundEscrow: EscrowService['refundEscrow'];
let getEscrow: EscrowService['getEscrow'];

// The escrow store is module-level. We reset all mocks between tests
// to prevent state from leaking. For full isolation, the store would
// need to be injectable — this is tracked in issue #003.
beforeEach(() => {
vi.resetModules();
({ depositEscrow, releaseEscrow, refundEscrow, getEscrow } = createEscrowService(new MemoryAdapter()));
});

describe('depositEscrow', () => {
Expand Down
34 changes: 15 additions & 19 deletions backend/src/__tests__/queueService.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createQueueService, type QueueService } from '../services/queueService.js';
import { MemoryAdapter } from '../storage/index.js';
import { QueueStatus } from '../schemas/queueStatus.js';

let createQueue: typeof import('../services/queueService.js').createQueue;
let getQueueById: typeof import('../services/queueService.js').getQueueById;
let advanceQueue: typeof import('../services/queueService.js').advanceQueue;
let closeQueue: typeof import('../services/queueService.js').closeQueue;
let getQueueStats: typeof import('../services/queueService.js').getQueueStats;
let openEnrollment: typeof import('../services/queueService.js').openEnrollment;
let closeEnrollment: typeof import('../services/queueService.js').closeEnrollment;
let QueueStatus: typeof import('../schemas/queueStatus.js').QueueStatus;
// Issue #91: replaces the fragile `import('...?t=' + Date.now())` reload hack
// with a fresh service over an injected store per test — real isolation.
let createQueue: QueueService['createQueue'];
let getQueueById: QueueService['getQueueById'];
let advanceQueue: QueueService['advanceQueue'];
let closeQueue: QueueService['closeQueue'];
let getQueueStats: QueueService['getQueueStats'];
let openEnrollment: QueueService['openEnrollment'];
let closeEnrollment: QueueService['closeEnrollment'];

beforeEach(async () => {
const mod = await import('../services/queueService.js?t=' + Date.now());
createQueue = mod.createQueue;
getQueueById = mod.getQueueById;
advanceQueue = mod.advanceQueue;
closeQueue = mod.closeQueue;
getQueueStats = mod.getQueueStats;
openEnrollment = mod.openEnrollment;
closeEnrollment = mod.closeEnrollment;
const modEnum = await import('../schemas/queueStatus.js?t=' + Date.now());
QueueStatus = modEnum.QueueStatus;
beforeEach(() => {
({ createQueue, getQueueById, advanceQueue, closeQueue, getQueueStats, openEnrollment, closeEnrollment } =
createQueueService(new MemoryAdapter()));
});

describe('createQueue', () => {
Expand Down
12 changes: 11 additions & 1 deletion backend/src/routes/queues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@ import { NotFoundError, ValidationError } from '../errors/index.js';

const router: IRouter = Router();

// Issue #86: a slug longer than Soroban's 9-char `Symbol` limit panics on-chain
// with no actionable cause. Constrain it at the API boundary to the same shape
// the SDK's `validateSlug` enforces — lowercase alphanumeric words joined by
// single hyphens, at most 64 characters (a `soroban_sdk::String` queue id) —
// so an invalid slug is rejected with a 400 long before a transaction is built.
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const CreateQueueSchema = z.object({
name: z.string().min(1).max(120),
slug: z.string().min(1).max(120),
slug: z
.string()
.min(1)
.max(64)
.regex(SLUG_PATTERN, 'Slug must be lowercase alphanumeric words separated by single hyphens'),
maxPositions: z.number().int().positive(),
advancementRule: z.enum(['FIFO', 'Priority', 'VerifiableRandomness']).optional(),
escrowRequired: z.boolean().optional(),
Expand Down
143 changes: 85 additions & 58 deletions backend/src/services/enrollmentService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defaultMemoryAdapter } from '../storage/index.js';
import { defaultMemoryAdapter, type StorageAdapter } from '../storage/index.js';
import { serviceEmitter } from './eventEmitter.js';

export type EnrollmentRecord = {
Expand All @@ -9,70 +9,97 @@ export type EnrollmentRecord = {
cancelled: boolean;
};

// Enrollment state lives in the shared storage adapter (issue #4) instead of
// module-level Maps. Records are keyed by identity; a per-queue index of
// identities supports queue-level lookups. The MemoryAdapter stores references,
// so in-place mutation of a record (e.g. cancellation) persists as before.
const store = defaultMemoryAdapter;
const NS_BY_IDENTITY = 'enrollments:byIdentity';
const NS_QUEUE_INDEX = 'enrollments:queueIndex';

export const enrollIdentity = (queueId: string, identity: string): EnrollmentRecord => {
const existing = store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity) ?? [];
const conflict = existing.some((item) => item.queueId === queueId && !item.cancelled);
if (conflict) {
return { queueId, identity, enrolledAt: new Date().toISOString(), conflict: true, cancelled: false };
}
const record: EnrollmentRecord = {
queueId,
identity,
enrolledAt: new Date().toISOString(),
conflict: false,
cancelled: false,
export interface EnrollmentService {
enrollIdentity(queueId: string, identity: string): EnrollmentRecord;
cancelEnrollment(queueId: string, identity: string): boolean;
getEnrollmentsByIdentity(identity: string): EnrollmentRecord[];
getEnrollment(identity: string): EnrollmentRecord[];
getEnrollmentsByQueue(queueId: string): EnrollmentRecord[];
}

/**
* Build an enrollment service over an injected storage adapter (issue #91).
* Records are keyed by identity, with a per-queue index of identities for
* queue-level lookups. Pass a fresh adapter in tests for full isolation.
*/
export function createEnrollmentService(store: StorageAdapter = defaultMemoryAdapter): EnrollmentService {
const byIdentity = (identity: string) =>
(store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity) as EnrollmentRecord[] | undefined) ?? [];
const queueSet = (queueId: string) =>
store.get<Set<string>>(NS_QUEUE_INDEX, queueId) as Set<string> | undefined;

const enrollIdentity: EnrollmentService['enrollIdentity'] = (queueId, identity) => {
const existing = byIdentity(identity);
const conflict = existing.some((item) => item.queueId === queueId && !item.cancelled);
if (conflict) {
return { queueId, identity, enrolledAt: new Date().toISOString(), conflict: true, cancelled: false };
}
const record: EnrollmentRecord = {
queueId,
identity,
enrolledAt: new Date().toISOString(),
conflict: false,
cancelled: false,
};
existing.push(record);
store.set<EnrollmentRecord[]>(NS_BY_IDENTITY, identity, existing);

const set = queueSet(queueId) ?? new Set<string>();
set.add(identity);
store.set<Set<string>>(NS_QUEUE_INDEX, queueId, set);

serviceEmitter.emit('enrollment.created', record);
return record;
};
existing.push(record);
store.set<EnrollmentRecord[]>(NS_BY_IDENTITY, identity, existing);

// Maintain queue-level index
const queueSet = store.get<Set<string>>(NS_QUEUE_INDEX, queueId) ?? new Set<string>();
queueSet.add(identity);
store.set<Set<string>>(NS_QUEUE_INDEX, queueId, queueSet);
const cancelEnrollment: EnrollmentService['cancelEnrollment'] = (queueId, identity) => {
const existing = store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity) as EnrollmentRecord[] | undefined;
if (!existing) return false;
const record = existing.find((r) => r.queueId === queueId && !r.cancelled);
if (!record) return false;
record.cancelled = true;
store.set<EnrollmentRecord[]>(NS_BY_IDENTITY, identity, existing);
const set = queueSet(queueId);
if (set) {
set.delete(identity);
store.set<Set<string>>(NS_QUEUE_INDEX, queueId, set);
}
serviceEmitter.emit('enrollment.cancelled', { queueId, identity });
return true;
};

serviceEmitter.emit('enrollment.created', record);
return record;
};
const getEnrollmentsByIdentity: EnrollmentService['getEnrollmentsByIdentity'] = (identity) =>
byIdentity(identity);

export const cancelEnrollment = (queueId: string, identity: string): boolean => {
const existing = store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity);
if (!existing) return false;
const record = existing.find((r) => r.queueId === queueId && !r.cancelled);
if (!record) return false;
record.cancelled = true;
store.set<EnrollmentRecord[]>(NS_BY_IDENTITY, identity, existing);
const queueSet = store.get<Set<string>>(NS_QUEUE_INDEX, queueId);
if (queueSet) {
queueSet.delete(identity);
store.set<Set<string>>(NS_QUEUE_INDEX, queueId, queueSet);
}
serviceEmitter.emit('enrollment.cancelled', { queueId, identity });
return true;
};
const getEnrollmentsByQueue: EnrollmentService['getEnrollmentsByQueue'] = (queueId) => {
const identities = queueSet(queueId);
if (!identities) return [];
const results: EnrollmentRecord[] = [];
for (const identity of identities) {
const records = byIdentity(identity);
results.push(...records.filter((r) => r.queueId === queueId && !r.cancelled));
}
return results;
};

export const getEnrollmentsByIdentity = (identity: string): EnrollmentRecord[] => {
return store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity) ?? [];
};
return {
enrollIdentity,
cancelEnrollment,
getEnrollmentsByIdentity,
getEnrollment: getEnrollmentsByIdentity,
getEnrollmentsByQueue,
};
}

/** @deprecated use getEnrollmentsByIdentity */
export const getEnrollment = getEnrollmentsByIdentity;
/** Default singleton over the shared adapter — used by production route handlers. */
export const enrollmentService = createEnrollmentService();

export const getEnrollmentsByQueue = (queueId: string): EnrollmentRecord[] => {
const identities = store.get<Set<string>>(NS_QUEUE_INDEX, queueId);
if (!identities) return [];
const results: EnrollmentRecord[] = [];
for (const identity of identities) {
const records = store.get<EnrollmentRecord[]>(NS_BY_IDENTITY, identity) ?? [];
const active = records.filter((r) => r.queueId === queueId && !r.cancelled);
results.push(...active);
}
return results;
};
export const enrollIdentity = enrollmentService.enrollIdentity;
export const cancelEnrollment = enrollmentService.cancelEnrollment;
export const getEnrollmentsByIdentity = enrollmentService.getEnrollmentsByIdentity;
/** @deprecated use getEnrollmentsByIdentity */
export const getEnrollment = enrollmentService.getEnrollmentsByIdentity;
export const getEnrollmentsByQueue = enrollmentService.getEnrollmentsByQueue;
Loading
Loading