diff --git a/.gitignore b/.gitignore index e61f67e..2f5c19e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ temp/ # Config files .mock-config.json -docs/ \ No newline at end of file +docs/ + +# Mock data persistence +.mock-data.json +.mock-data.json.tmp \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f1b4b3d..aa41113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,22 @@ TypeScript is strict (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexe - `x-mock-status: ` — forces the response HTTP status code (handled by `src/middlewares/statusOverride.ts`; ignored in `strict` mode by not mounting the middleware) **System routes** (not matched by dynamic handler): -- `GET /health` — server status + cache stats -- `GET /api-docs` — Swagger UI (spec auto-regenerated on hot-reload file changes) +- `GET /health` — server status + cache stats + list of available type names (`types: string[]`) +- `GET /api-docs` — Swagger UI (spec auto-regenerated on hot-reload file changes; includes type-selector dropdown for selective rebuild) +- `POST /mock-reset` — clear all mock data and re-seed +- `POST /mock-reset/:typeName` — regenerate mock data for a single type only; 404 if type unknown + +**JSON persistence** (`persistData?: string | false` in `ServerConfig`): +- Opt-in via `--persist-data [path]` CLI or wizard advanced options (default path: `.mock-data.json`) +- Startup: `seedAllPools` runs first, then if file exists it loads pools from file (corrupt files are left untouched); then saves to ensure file matches `typesDir` +- After POST/PUT/PATCH/DELETE: `saveMockData` is called from `router.ts` via `maybePersist(config)` +- After `/mock-reset` or hot-reload: file is overwritten with fresh data +- `POST /mock-reset/:typeName`: only the target type is regenerated and saved +- File format: `{ TypeName: [...items] }` — keyed by TypeScript interface name, not route name +- Atomic write: write to `.mock-data.json.tmp` then `rename` (no corruption on interruption) +- Empty array `[]` is a valid persisted state (all items deleted) — not regenerated on reload +- Unknown keys in the file are silently skipped (debug log emitted) +- Module: `src/utils/dataPersistence.ts` — `saveMockData(store, typesDir, filePath)` and `loadMockData(store, typesDir, filePath)` +- `MockDataStore.getLivePool(typeName, filePath)` — the authoritative merge of pool + writeStore − deletedIds; used by both the router (GET collection) and the persistence module **Key types** (`src/types/config.ts`): `ServerConfig`, `MockMode`, `RouteTypeMapping`, `InterfaceMetadata`, `ParsedSchema`, `MockGenerationOptions` diff --git a/README.md b/README.md index 4b5b207..ecc68e0 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Options: -p, --port Server port (default: 8080) -l, --latency Latency simulation "min-max" (e.g., 500-2000) --mock-mode Mock mode (default: dev) + --persist-data [path] Persist mock data to JSON file (default: .mock-data.json) --no-hot-reload Disable auto-reload on changes --no-cache Disable schema caching -v, --verbose Enable verbose logging @@ -306,6 +307,77 @@ export interface Product { --- +## JSON Persistence + +By default, mock data is purely in-memory and resets on every server restart. Enable persistence to keep data between sessions or share a stable dataset across restarts. + +### Activation + +**CLI:** +```bash +npx ts-mock-proxy --types-dir ./types --persist-data +# Uses default path: .mock-data.json + +npx ts-mock-proxy --types-dir ./types --persist-data ./data/mocks.json +# Custom path +``` + +**Wizard:** enable in the advanced options section. + +**Config file** (`.mock-config.json`): +```json +{ "persistData": ".mock-data.json" } +``` + +### File Format + +The file is a flat JSON object keyed by TypeScript interface name: + +```json +{ + "User": [ + { "id": 1, "name": "Alice", "email": "alice@example.com" }, + { "id": 2, "name": "Bob", "email": "bob@example.com" } + ], + "Post": [ + { "id": 1, "title": "Hello World", "authorId": 1 } + ] +} +``` + +You can edit this file manually while the server is stopped. Changes are loaded on the next startup. + +> **Warning:** if the server is running and a mutation (POST/PUT/PATCH/DELETE) occurs between your manual edit and the next restart, the automatic save will overwrite your edits. Stop the server before editing the file. + +### Behaviour + +| Situation | Result | +|---|---| +| First launch, no file | File created with generated data | +| Restart, file present | Pools replaced by file content | +| New type added to `typesDir` | Generated and added to file at startup | +| POST / PUT / PATCH / DELETE | File updated atomically after every mutation | +| `POST /mock-reset` | File overwritten with freshly generated data | +| `POST /mock-reset/User` | Only `User` regenerated and saved | +| `[]` in file | Preserved as-is — not regenerated (intentional empty state) | +| Invalid JSON in file | Warning emitted, server starts normally, file not overwritten | +| Hot-reload (type file changed) | Affected types regenerated and saved; others untouched | + +Writes are atomic: the server writes to `.mock-data.json.tmp` first then renames it, so an interrupted write never corrupts the existing file. + +### Selective Rebuild + +Regenerate one type without affecting others: + +```bash +POST /mock-reset/User +# → {"message": "Mock data regenerated for type \"User\"", "type": "User", "count": 10} +``` + +The Swagger UI also exposes a type selector dropdown ("Select type… → Rebuild selected") next to the existing "Rebuild Data" (all) button. + +--- + ## Mock Modes The server supports two modes controlled by `mockMode`: diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index 8b9627e..5916eb8 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -123,6 +123,7 @@ export async function runWizard(): Promise { let verbose = savedConfig?.verbose ?? false; let writeMethods: ServerConfig['writeMethods'] = savedConfig?.writeMethods; let mockMode: MockMode = savedConfig?.mockMode ?? 'dev'; + let persistData: string | false = savedConfig?.persistData ?? false; if (advancedAnswer.showAdvanced) { const advOptions = await inquirer.prompt([ @@ -214,6 +215,30 @@ export async function runWizard(): Promise { latency = undefined; } + // Persistence configuration + const persistAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'enablePersist', + message: 'Persist mock data to JSON file?', + default: !!persistData, + }, + ]); + + if (persistAnswer.enablePersist) { + const persistPathAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'persistPath', + message: 'Path to persist file?', + default: typeof persistData === 'string' ? persistData : '.mock-data.json', + }, + ]); + persistData = persistPathAnswer.persistPath as string; + } else { + persistData = false; + } + // Write methods configuration const writeMethodsAnswer = await inquirer.prompt([ { @@ -259,6 +284,7 @@ export async function runWizard(): Promise { verbose, writeMethods, mockMode, + persistData: persistData || undefined, }; displayConfigSummary(config); @@ -312,6 +338,7 @@ function displayConfigSummary(config: ServerConfig): void { console.log(` ${chalk.cyan('Hot-reload:')} ${config.hotReload ? 'enabled' : 'disabled'}`); console.log(` ${chalk.cyan('Cache:')} ${config.cache ? 'enabled' : 'disabled'}`); console.log(` ${chalk.cyan('Verbose:')} ${config.verbose ? 'enabled' : 'disabled'}`); + console.log(` ${chalk.cyan('Persist data:')} ${config.persistData ? config.persistData : 'disabled'}`); const wm = config.writeMethods; if (wm) { diff --git a/src/core/cache.ts b/src/core/cache.ts index a6f58fd..c13244e 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -1,5 +1,6 @@ import { ParsedSchema } from '../types/config'; import { logger } from '../utils/logger'; +import { extractMockId } from '../utils/mockId'; /** * In-memory cache for parsed TypeScript schemas @@ -223,6 +224,13 @@ export class MockDataStore { } } + invalidateType(typeName: string, filePath: string): void { + const k = this.key(typeName, filePath); + this.pools.delete(k); + this.writeStore.delete(k); + this.deletedIds.delete(k); + } + clear(): { pools: number } { const pools = this.pools.size; this.pools.clear(); @@ -235,6 +243,31 @@ export class MockDataStore { getStats(): { pools: number } { return { pools: this.pools.size }; } + + /** + * Returns the live pool for a type: write-store entries merged with the seeded pool, + * excluding deleted IDs. Write-store entries take precedence over pool items. + */ + getLivePool(typeName: string, filePath: string): Record[] { + const pool = this.pools.get(this.key(typeName, filePath))?.data ?? []; + const deletedIds = this.deletedIds.get(this.key(typeName, filePath)) ?? new Set(); + const writeEntries = this.writeStore.get(this.key(typeName, filePath)) ?? new Map>(); + + const fromPool = pool.filter((item) => { + const id = extractMockId(item); + if (id === undefined) return true; + if (deletedIds.has(id)) return false; + if (writeEntries.has(id)) return false; + return true; + }); + + const fromWriteStore = Array.from(writeEntries.values()).filter((item) => { + const id = extractMockId(item); + return id === undefined || !deletedIds.has(id); + }); + + return [...fromWriteStore, ...fromPool]; + } } export const mockDataStore = new MockDataStore(); diff --git a/src/core/router.ts b/src/core/router.ts index 790a5ca..49d33b0 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { Request, Response } from 'express'; import { ServerConfig, ApiErrorResponse } from '../types/config'; import { findTypeForUrl } from '../utils/typeMapping'; @@ -5,6 +6,8 @@ import { parseUrlSegments, isIdSegment } from '../utils/pluralize'; import { generateMockFromInterface, generateMockArray } from './parser'; import { mockDataStore } from './cache'; import { logger } from '../utils/logger'; +import { saveMockData } from '../utils/dataPersistence'; +import { extractMockId } from '../utils/mockId'; import { parseQueryParams, validateSortFields, @@ -28,16 +31,6 @@ function extractIdFromUrl(url: string): string | undefined { return undefined; } -/** Returns the value of the first recognised ID field (id, uuid, _id) in a mock object. */ -function extractMockId(obj: Record): string | undefined { - for (const field of ['id', 'uuid', '_id']) { - if (obj[field] !== undefined) { - return String(obj[field]); - } - } - return undefined; -} - /** Returns the name of the first recognised ID field present in the object, if any. */ function findIdField(obj: Record): string | undefined { for (const field of ['id', 'uuid', '_id']) { @@ -90,32 +83,10 @@ function updatePoolEntry( mockDataStore.setPool(typeName, filePath, pool); } -/** - * Builds the "live pool" for a collection endpoint by merging the seeded pool - * with write-store entries, excluding deleted items and replacing overridden ones. - */ -function buildLivePool( - typeName: string, - filePath: string, - pool: Record[] -): Record[] { - const deletedIds = mockDataStore.getDeletedIds(typeName, filePath); - const writeEntries = mockDataStore.getAllWriteEntries(typeName, filePath); - - const fromPool = pool.filter((item) => { - const id = extractMockId(item); - if (id === undefined) return true; - if (deletedIds.has(id)) return false; - if (writeEntries.has(id)) return false; // write-store version takes precedence - return true; - }); - - const fromWriteStore = Array.from(writeEntries.values()).filter((item) => { - const id = extractMockId(item); - return id === undefined || !deletedIds.has(id); - }); - - return [...fromWriteStore, ...fromPool]; +/** Saves mock data to the persist file if persistData is configured. */ +function maybePersist(config: ServerConfig): void { + if (!config.persistData) return; + saveMockData(mockDataStore, config.typesDir, path.resolve(config.persistData)); } // --------------------------------------------------------------------------- @@ -148,13 +119,12 @@ async function handleGet( } // Seed pool on first request - let pool = mockDataStore.getPool(mapping.typeName, filePath); - if (!pool) { - pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE }); + if (!mockDataStore.getPool(mapping.typeName, filePath)) { + const pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE }); mockDataStore.setPool(mapping.typeName, filePath, pool); } - const livePool = buildLivePool(mapping.typeName, filePath, pool); + const livePool = mockDataStore.getLivePool(mapping.typeName, filePath); if (parsed.sort.length > 0 && livePool.length > 0) { const firstItem = livePool[0]; @@ -252,6 +222,7 @@ async function handlePost( const basePath = req.path.replace(/\/$/, ''); const location = id !== undefined ? `${basePath}/${id}` : basePath; + maybePersist(config); res.status(forcedStatus || 201).set('Location', location).json(merged); } @@ -305,6 +276,7 @@ async function handlePut( updatePoolEntry(mapping.typeName, filePath, id, merged); } + maybePersist(config); res.status(forcedStatus || 200).json(merged); } @@ -373,6 +345,7 @@ async function handlePatch( updatePoolEntry(mapping.typeName, filePath, id, merged); } + maybePersist(config); res.status(forcedStatus || 200).json(merged); } @@ -415,6 +388,7 @@ async function handleDelete( } } + maybePersist(config); res.status(forcedStatus || 204).send(); } diff --git a/src/index.ts b/src/index.ts index 8b22aea..c416ff3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ async function main() { .option('--no-cache', 'Disable schema caching') .option('-v, --verbose', 'Enable verbose logging', false) .option('--mock-mode ', 'Mock mode: "dev" enables all mock features (default), "strict" disables them') + .option('--persist-data [path]', 'Persist mock data to JSON file (default path: .mock-data.json)') .option('--interactive', 'Force interactive mode') .action(async (options) => { // If --interactive flag is set, run wizard instead @@ -77,6 +78,14 @@ async function main() { // Resolve mockMode: CLI > ENV > default const mockMode = resolveMockMode(options.mockMode); + // Resolve persistData: --persist-data with no arg → default path; with path → use it; absent → disabled + let persistData: string | undefined; + if (options.persistData !== undefined) { + persistData = typeof options.persistData === 'string' && options.persistData !== '' + ? options.persistData + : '.mock-data.json'; + } + // Build the configuration const config: ServerConfig = { typesDir, @@ -86,6 +95,7 @@ async function main() { cache: options.cache !== false, verbose: options.verbose, mockMode, + persistData, }; // Configure the global cache diff --git a/src/server.ts b/src/server.ts index cd5e281..478dca3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as path from 'path'; import express, { Express, Request, Response, NextFunction } from 'express'; import cors from 'cors'; import swaggerUi from 'swagger-ui-express'; @@ -13,6 +15,7 @@ import { generateOpenAPISpec } from './core/swagger'; import { buildTypeMap } from './utils/typeMapping'; import { generateMockArray } from './core/parser'; import { POOL_SIZE } from './core/queryProcessor'; +import { saveMockData, loadMockData } from './utils/dataPersistence'; import type { FSWatcher } from 'chokidar'; /** @@ -43,6 +46,18 @@ export function createServer(config: ServerConfig): Express { // Eagerly seed all collection pools so GET /{col}/{id} works immediately seedAllPools(config); + // Persistence: load from file (if it exists) or create the file with generated data + if (config.persistData) { + const persistPath = path.resolve(config.persistData); + const fileExisted = fs.existsSync(persistPath); + const loaded = loadMockData(mockDataStore, config.typesDir, persistPath); + // Only write when: (a) file didn't exist yet (first launch), or (b) file loaded correctly. + // A corrupt file is left untouched so the user can fix or delete it manually. + if (!fileExisted || loaded) { + saveMockData(mockDataStore, config.typesDir, persistPath); + } + } + // Global middlewares app.use(cors()); app.use(express.json()); @@ -62,11 +77,13 @@ export function createServer(config: ServerConfig): Express { // Health route app.get('/health', (_req, res) => { + const typeMap = buildTypeMap(config.typesDir); res.json({ status: 'ok', uptime: process.uptime(), cache: schemaCache.getStats(), writeStore: mockDataStore.getWriteStats(), + types: Array.from(typeMap.keys()), config: { typesDir: config.typesDir, port: config.port, @@ -77,7 +94,7 @@ export function createServer(config: ServerConfig): Express { }); }); - // Custom JS injected into Swagger UI — adds a "Rebuild Data" button + // Custom JS injected into Swagger UI — adds "Rebuild Data" (all) and "Rebuild selected" (by type) buttons app.get('/swagger-rebuild.js', (_req, res) => { res.type('js').send(` window.addEventListener('load', function () { @@ -87,15 +104,16 @@ window.addEventListener('load', function () { clearInterval(interval); var toolbar = document.createElement('div'); - toolbar.style.cssText = 'background:#1b1b1b;padding:8px 20px;display:flex;align-items:center;gap:12px;'; + toolbar.style.cssText = 'background:#1b1b1b;padding:8px 20px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;'; var label = document.createElement('span'); label.textContent = 'TS Mock API'; label.style.cssText = 'color:#fff;font-family:sans-serif;font-size:15px;font-weight:700;flex:1;'; + // "Rebuild all" button var btn = document.createElement('button'); btn.textContent = 'Rebuild Data'; - btn.title = 'Clear all cached mock data and regenerate on next requests'; + btn.title = 'Clear all cached mock data and regenerate'; btn.style.cssText = 'background:#49cc90;color:#fff;border:none;padding:6px 18px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:700;font-family:sans-serif;'; btn.addEventListener('click', function () { @@ -118,7 +136,61 @@ window.addEventListener('load', function () { }); }); + // Type selector dropdown + var select = document.createElement('select'); + select.style.cssText = 'background:#2d2d2d;color:#fff;border:1px solid #555;padding:5px 10px;border-radius:4px;font-size:13px;font-family:sans-serif;cursor:pointer;'; + + var placeholder = document.createElement('option'); + placeholder.textContent = 'Select type\u2026'; + placeholder.value = ''; + select.appendChild(placeholder); + + // "Rebuild selected" button + var rebuildBtn = document.createElement('button'); + rebuildBtn.textContent = 'Rebuild selected'; + rebuildBtn.style.cssText = 'background:#61affe;color:#fff;border:none;padding:6px 18px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:700;font-family:sans-serif;'; + + rebuildBtn.addEventListener('click', function () { + var type = select.value; + if (!type) return; + rebuildBtn.disabled = true; + rebuildBtn.textContent = 'Rebuilding\u2026'; + fetch('/mock-reset/' + encodeURIComponent(type), { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + rebuildBtn.textContent = 'Done (' + (data.count || 0) + ' items)'; + setTimeout(function () { + rebuildBtn.textContent = 'Rebuild selected'; + rebuildBtn.disabled = false; + }, 2000); + }) + .catch(function () { + rebuildBtn.style.background = '#f93e3e'; + rebuildBtn.textContent = 'Error'; + rebuildBtn.disabled = false; + setTimeout(function () { + rebuildBtn.style.background = '#61affe'; + rebuildBtn.textContent = 'Rebuild selected'; + }, 2500); + }); + }); + + // Populate the type dropdown from /health + fetch('/health') + .then(function (r) { return r.json(); }) + .then(function (data) { + var types = data.types || []; + types.forEach(function (t) { + var opt = document.createElement('option'); + opt.value = t; + opt.textContent = t; + select.appendChild(opt); + }); + }); + toolbar.appendChild(label); + toolbar.appendChild(select); + toolbar.appendChild(rebuildBtn); toolbar.appendChild(btn); container.parentNode.insertBefore(toolbar, container); }, 100); @@ -126,14 +198,46 @@ window.addEventListener('load', function () { `); }); - // Mock data reset endpoint + // Mock data reset endpoint (full reset) app.post('/mock-reset', (_req, res) => { const mockCleared = mockDataStore.clear(); schemaCache.clear(); seedAllPools(config); + if (config.persistData) { + saveMockData(mockDataStore, config.typesDir, path.resolve(config.persistData)); + } res.json({ message: 'Mock data store cleared', cleared: mockCleared }); }); + // Selective reset endpoint — regenerates mock data for a single type + app.post('/mock-reset/:typeName', (req, res) => { + const { typeName } = req.params; + const typeMap = buildTypeMap(config.typesDir); + const typeFilePath = typeMap.get(typeName); + + if (!typeFilePath) { + res.status(404).json({ + error: 'Not Found', + message: `Unknown type "${typeName}". No @endpoint interface matches this name.`, + }); + return; + } + + mockDataStore.invalidateType(typeName, typeFilePath); + const newPool = generateMockArray(typeFilePath, typeName, { arrayLength: POOL_SIZE }); + mockDataStore.setPool(typeName, typeFilePath, newPool); + + if (config.persistData) { + saveMockData(mockDataStore, config.typesDir, path.resolve(config.persistData)); + } + + res.json({ + message: `Mock data regenerated for type "${typeName}"`, + type: typeName, + count: newPool.length, + }); + }); + // Swagger documentation - use app.locals.swaggerSpec for dynamic updates app.use('/api-docs', swaggerUi.serve, (req: Request, res: Response, next: NextFunction) => { const spec = app.locals.swaggerSpec || swaggerSpec; @@ -200,6 +304,12 @@ export function startServer( const newSwaggerSpec = generateOpenAPISpec(config); app.locals.swaggerSpec = newSwaggerSpec; seedAllPools(config); + + // Persist updated pools (affected types now have freshly generated data) + if (config.persistData) { + saveMockData(mockDataStore, config.typesDir, path.resolve(config.persistData)); + } + logger.success('Swagger spec regenerated with updated endpoints'); }); } diff --git a/src/types/config.ts b/src/types/config.ts index 91c03be..24adacf 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -43,6 +43,13 @@ export interface ServerConfig { patch?: boolean; delete?: boolean; }; + + /** + * Path to persist mock data to a JSON file (absolute or relative to CWD). + * Set to false or leave undefined to disable persistence (default). + * Recommended value when enabled: '.mock-data.json' + */ + persistData?: string | false; } /** diff --git a/src/utils/configPersistence.ts b/src/utils/configPersistence.ts index c1b110a..5c130a6 100644 --- a/src/utils/configPersistence.ts +++ b/src/utils/configPersistence.ts @@ -22,6 +22,7 @@ function isValidSavedConfig(obj: unknown): obj is ServerConfig { const latency = s['latency'] as Record; if (typeof latency['min'] !== 'number' || typeof latency['max'] !== 'number') return false; } + if (s['persistData'] !== undefined && s['persistData'] !== false && typeof s['persistData'] !== 'string') return false; return true; } diff --git a/src/utils/dataPersistence.ts b/src/utils/dataPersistence.ts new file mode 100644 index 0000000..024c7de --- /dev/null +++ b/src/utils/dataPersistence.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import { MockDataStore } from '../core/cache'; +import { buildTypeMap } from './typeMapping'; +import { logger } from './logger'; + +/** + * Serializes the live pool of every @endpoint type to a JSON file via atomic write + * (write to .tmp then rename — prevents corruption on interrupted writes). + */ +export function saveMockData(store: MockDataStore, typesDir: string, filePath: string): void { + try { + const typeMap = buildTypeMap(typesDir); + const data: Record[]> = {}; + + typeMap.forEach((typeFilePath, typeName) => { + data[typeName] = store.getLivePool(typeName, typeFilePath); + }); + + const json = JSON.stringify(data, null, 2); + const tmpPath = `${filePath}.tmp`; + + fs.writeFileSync(tmpPath, json, 'utf-8'); + fs.renameSync(tmpPath, filePath); + + const count = Object.keys(data).length; + logger.info(`Mock data persisted to ${filePath} (${count} type(s))`); + } catch (error) { + logger.warn(`Failed to save mock data: ${error}`); + } +} + +/** + * Loads mock data from a JSON persist file and replaces the in-memory pools for known types. + * Unknown types are silently skipped (with a debug log). + * Returns true if the file was loaded, false if absent or invalid (invalid files are NOT overwritten). + */ +export function loadMockData(store: MockDataStore, typesDir: string, filePath: string): boolean { + try { + if (!fs.existsSync(filePath)) { + return false; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + let parsed: unknown; + + try { + parsed = JSON.parse(content); + } catch (parseError) { + logger.warn(`Failed to load mock data: ${parseError}`); + return false; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + logger.warn(`Failed to load mock data: root value must be an object`); + return false; + } + + const typeMap = buildTypeMap(typesDir); + const data = parsed as Record; + let loadedCount = 0; + + for (const [typeName, items] of Object.entries(data)) { + const typeFilePath = typeMap.get(typeName); + if (!typeFilePath) { + logger.debug(`Skipping unknown type "${typeName}" from persist file`); + continue; + } + + if (!Array.isArray(items)) { + logger.debug(`Skipping type "${typeName}": expected array, got ${typeof items}`); + continue; + } + + store.setPool(typeName, typeFilePath, items as Record[]); + loadedCount++; + } + + logger.info(`Mock data loaded from ${filePath} (${loadedCount} type(s))`); + return true; + } catch (error) { + logger.warn(`Failed to load mock data: ${error}`); + return false; + } +} diff --git a/src/utils/mockId.ts b/src/utils/mockId.ts new file mode 100644 index 0000000..c46983d --- /dev/null +++ b/src/utils/mockId.ts @@ -0,0 +1,7 @@ +/** Returns the value of the first recognised ID field (id, uuid, _id) in a mock object. */ +export function extractMockId(obj: Record): string | undefined { + for (const field of ['id', 'uuid', '_id']) { + if (obj[field] !== undefined) return String(obj[field]); + } + return undefined; +} diff --git a/tests/core/cache.test.ts b/tests/core/cache.test.ts index 8d35d8a..b9b3802 100644 --- a/tests/core/cache.test.ts +++ b/tests/core/cache.test.ts @@ -220,4 +220,90 @@ describe('MockDataStore', () => { expect(store.getStats()).toEqual({ pools: 0 }); }); }); + + describe('getLivePool', () => { + it('returns pool alone when write store and deletedIds are empty', () => { + const pool = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]; + store.setPool('User', '/path/user.ts', pool); + expect(store.getLivePool('User', '/path/user.ts')).toEqual(pool); + }); + + it('returns write store entries in priority over pool items with same ID', () => { + store.setPool('User', '/path/user.ts', [{ id: 1, name: 'Alice' }]); + store.setById('User', '/path/user.ts', '1', { id: 1, name: 'Alice Updated' }); + const live = store.getLivePool('User', '/path/user.ts'); + expect(live).toHaveLength(1); + expect(live[0]).toEqual({ id: 1, name: 'Alice Updated' }); + }); + + it('excludes IDs marked deleted', () => { + store.setPool('User', '/path/user.ts', [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]); + store.markDeleted('User', '/path/user.ts', '1'); + const live = store.getLivePool('User', '/path/user.ts'); + expect(live).toHaveLength(1); + expect(live[0]).toEqual({ id: 2, name: 'Bob' }); + }); + + it('returns [] when all items are deleted', () => { + store.setPool('User', '/path/user.ts', [{ id: 1 }, { id: 2 }]); + store.markDeleted('User', '/path/user.ts', '1'); + store.markDeleted('User', '/path/user.ts', '2'); + expect(store.getLivePool('User', '/path/user.ts')).toEqual([]); + }); + + it('merges write store entries with remaining pool items', () => { + store.setPool('User', '/path/user.ts', [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + // POST creates a new item (id: 3) in write store + store.setById('User', '/path/user.ts', '3', { id: 3, name: 'Charlie' }); + const live = store.getLivePool('User', '/path/user.ts'); + expect(live).toHaveLength(3); + const names = live.map((item) => item['name']); + expect(names).toContain('Alice'); + expect(names).toContain('Bob'); + expect(names).toContain('Charlie'); + }); + + it('returns [] when no pool has been seeded', () => { + expect(store.getLivePool('Unknown', '/path/unknown.ts')).toEqual([]); + }); + }); + + describe('invalidateType', () => { + it('removes pool for the given type', () => { + store.setPool('User', '/path/user.ts', [{ id: 1, name: 'Alice' }]); + store.invalidateType('User', '/path/user.ts'); + expect(store.getPool('User', '/path/user.ts')).toBeUndefined(); + }); + + it('removes write store entries for the given type', () => { + store.setById('User', '/path/user.ts', '1', { id: 1, name: 'Updated' }); + store.invalidateType('User', '/path/user.ts'); + expect(store.getAllWriteEntries('User', '/path/user.ts').size).toBe(0); + }); + + it('removes deletedIds for the given type', () => { + store.setPool('User', '/path/user.ts', [{ id: 1 }]); + store.markDeleted('User', '/path/user.ts', '1'); + store.invalidateType('User', '/path/user.ts'); + expect(store.getDeletedIds('User', '/path/user.ts').size).toBe(0); + }); + + it('does not affect other types', () => { + store.setPool('User', '/path/user.ts', [{ id: 1 }]); + store.setPool('Post', '/path/post.ts', [{ id: 2 }]); + store.invalidateType('User', '/path/user.ts'); + expect(store.getPool('Post', '/path/post.ts')).toEqual([{ id: 2 }]); + }); + + it('getLivePool returns [] after invalidateType', () => { + store.setPool('User', '/path/user.ts', [{ id: 1, name: 'Alice' }]); + store.setById('User', '/path/user.ts', '2', { id: 2, name: 'Bob' }); + store.markDeleted('User', '/path/user.ts', '1'); + store.invalidateType('User', '/path/user.ts'); + expect(store.getLivePool('User', '/path/user.ts')).toEqual([]); + }); + }); }); diff --git a/tests/integration/server.integration.test.ts b/tests/integration/server.integration.test.ts index 0a771fd..3e96701 100644 --- a/tests/integration/server.integration.test.ts +++ b/tests/integration/server.integration.test.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import request from 'supertest'; import { createServer } from '../../src/server'; @@ -433,4 +435,199 @@ describe('Server integration', () => { expect(res.status).toBe(405); }); }); + + // --------------------------------------------------------------------------- + // GET /health — types list + // --------------------------------------------------------------------------- + describe('GET /health types list', () => { + it('includes a types array with available endpoint names', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('types'); + expect(Array.isArray(res.body.types)).toBe(true); + expect(res.body.types).toContain('User'); + }); + }); + + // --------------------------------------------------------------------------- + // POST /mock-reset/:typeName + // --------------------------------------------------------------------------- + describe('POST /mock-reset/:typeName', () => { + it('returns 200 with count for a known type', async () => { + const res = await request(app).post('/mock-reset/User'); + expect(res.status).toBe(200); + expect(res.body.type).toBe('User'); + expect(typeof res.body.count).toBe('number'); + expect(res.body.count).toBeGreaterThan(0); + expect(res.body.message).toMatch(/User/); + }); + + it('returns 404 for an unknown type', async () => { + const res = await request(app).post('/mock-reset/UnknownType'); + expect(res.status).toBe(404); + }); + + it('clears mutations — POSTed item is gone after reset', async () => { + // Add a sentinel item so we have a known mutation + await request(app).post('/api/users').send({ name: '__sentinel__' }); + const listBefore = await request(app).get('/api/users?pageSize=100'); + expect(listBefore.body.data.map((u: { name: unknown }) => u.name)).toContain('__sentinel__'); + + await request(app).post('/mock-reset/User'); + + const listAfter = await request(app).get('/api/users?pageSize=100'); + expect(listAfter.status).toBe(200); + expect(listAfter.body.data.length).toBeGreaterThan(0); + expect(listAfter.body.data.map((u: { name: unknown }) => u.name)).not.toContain('__sentinel__'); + }); + }); + + // --------------------------------------------------------------------------- + // persistData — JSON persistence + // --------------------------------------------------------------------------- + describe('persistData', () => { + function makeTempPath(): string { + return path.join(os.tmpdir(), `mock-persist-integration-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + } + + afterEach(() => { + invalidateTypeMap(); + mockDataStore.clear(); + }); + + it('does not create a file when persistData is not set', () => { + const filePath = makeTempPath(); + // testConfig has no persistData — file must not be created + expect(fs.existsSync(filePath)).toBe(false); + }); + + it('creates the persist file on first launch when persistData is set', () => { + const filePath = makeTempPath(); + try { + createServer({ ...testConfig, persistData: filePath }); + expect(fs.existsSync(filePath)).toBe(true); + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data).toHaveProperty('User'); + expect(Array.isArray(data['User'])).toBe(true); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('loads pools from existing file on startup', () => { + const filePath = makeTempPath(); + try { + const savedUsers = [{ id: 99, name: 'Persisted', email: 'p@example.com' }]; + fs.writeFileSync(filePath, JSON.stringify({ User: savedUsers }, null, 2), 'utf-8'); + + const persistApp = createServer({ ...testConfig, persistData: filePath }); + + return request(persistApp).get('/api/users?pageSize=100').then((res) => { + expect(res.status).toBe(200); + const ids = res.body.data.map((u: { id: unknown }) => u.id); + expect(ids).toContain(99); + }); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('updates the file after POST', async () => { + const filePath = makeTempPath(); + try { + const persistApp = createServer({ ...testConfig, persistData: filePath }); + + await request(persistApp).post('/api/users').send({ name: 'NewUser', email: 'n@e.com' }); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const names = (data['User'] as Array<{ name?: unknown }>).map((u) => u.name); + expect(names).toContain('NewUser'); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('updates file with [] after all items are deleted', async () => { + const filePath = makeTempPath(); + try { + // Start with exactly one user + const singleUser = [{ id: 1, name: 'Solo', email: 's@e.com' }]; + fs.writeFileSync(filePath, JSON.stringify({ User: singleUser }, null, 2), 'utf-8'); + + const persistApp = createServer({ ...testConfig, persistData: filePath }); + + await request(persistApp).delete('/api/users/1'); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data['User']).toEqual([]); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('POST /mock-reset overwrites file with fresh data', async () => { + const filePath = makeTempPath(); + try { + // Start with persisted data + const savedUsers = [{ id: 99, name: 'Old', email: 'o@e.com' }]; + fs.writeFileSync(filePath, JSON.stringify({ User: savedUsers }, null, 2), 'utf-8'); + + const persistApp = createServer({ ...testConfig, persistData: filePath }); + + await request(persistApp).post('/mock-reset'); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(data).toHaveProperty('User'); + // After reset, old ID 99 should be gone (fresh generation) + const ids = (data['User'] as Array<{ id?: unknown }>).map((u) => u.id); + expect(ids).not.toContain(99); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('POST /mock-reset/:typeName updates only the target type in the file', async () => { + const filePath = makeTempPath(); + try { + const persistApp = createServer({ ...testConfig, persistData: filePath }); + + // Record initial data + const before = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const usersBefore = JSON.stringify(before['User']); + + await request(persistApp).post('/mock-reset/User'); + + const after = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + // User pool was regenerated (different content expected) + expect(JSON.stringify(after['User'])).not.toBe(usersBefore); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('POST /mock-reset/:typeName returns 404 for unknown type', async () => { + const filePath = makeTempPath(); + try { + const persistApp = createServer({ ...testConfig, persistData: filePath }); + const res = await request(persistApp).post('/mock-reset/NonExistentType'); + expect(res.status).toBe(404); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + + it('starts normally when persist file contains invalid JSON (warning, no crash)', () => { + const filePath = makeTempPath(); + try { + fs.writeFileSync(filePath, '{ bad json!!', 'utf-8'); + expect(() => { + createServer({ ...testConfig, persistData: filePath }); + }).not.toThrow(); + // File is NOT overwritten when corrupt + expect(fs.readFileSync(filePath, 'utf-8')).toBe('{ bad json!!'); + } finally { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + }); + }); }); diff --git a/tests/utils/dataPersistence.test.ts b/tests/utils/dataPersistence.test.ts new file mode 100644 index 0000000..e140775 --- /dev/null +++ b/tests/utils/dataPersistence.test.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { MockDataStore } from '../../src/core/cache'; +import { saveMockData, loadMockData } from '../../src/utils/dataPersistence'; + +const FIXTURES_DIR = path.join(__dirname, '../fixtures/types'); + +function makeTempPath(): string { + return path.join(os.tmpdir(), `mock-persist-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); +} + +function cleanup(...paths: string[]): void { + for (const p of paths) { + try { fs.unlinkSync(p); } catch { /* ignore */ } + try { fs.unlinkSync(`${p}.tmp`); } catch { /* ignore */ } + } +} + +describe('saveMockData', () => { + let store: MockDataStore; + + beforeEach(() => { + store = new MockDataStore(); + }); + + it('creates the file with the expected format', () => { + const filePath = makeTempPath(); + try { + const users = [{ id: 1, name: 'Alice', email: 'a@b.com' }]; + // Pre-seed the store with a pool matching the fixture User type + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + store.setPool('User', typeFile, users); + + saveMockData(store, FIXTURES_DIR, filePath); + + expect(fs.existsSync(filePath)).toBe(true); + const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(content).toHaveProperty('User'); + expect(content['User']).toEqual(users); + } finally { + cleanup(filePath); + } + }); + + it('updates an existing file', () => { + const filePath = makeTempPath(); + try { + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + store.setPool('User', typeFile, [{ id: 1, name: 'Alice', email: 'a@b.com' }]); + saveMockData(store, FIXTURES_DIR, filePath); + + // Update the pool and save again + store.setPool('User', typeFile, [{ id: 2, name: 'Bob', email: 'b@c.com' }]); + saveMockData(store, FIXTURES_DIR, filePath); + + const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(content['User']).toEqual([{ id: 2, name: 'Bob', email: 'b@c.com' }]); + } finally { + cleanup(filePath); + } + }); + + it('writes via tmp + rename (tmp file absent after save)', () => { + const filePath = makeTempPath(); + try { + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + store.setPool('User', typeFile, [{ id: 1, name: 'A', email: 'a@b.com' }]); + saveMockData(store, FIXTURES_DIR, filePath); + + expect(fs.existsSync(`${filePath}.tmp`)).toBe(false); + expect(fs.existsSync(filePath)).toBe(true); + } finally { + cleanup(filePath); + } + }); + + it('preserves empty arrays in the file', () => { + const filePath = makeTempPath(); + try { + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + store.setPool('User', typeFile, []); + saveMockData(store, FIXTURES_DIR, filePath); + + const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + expect(content['User']).toEqual([]); + } finally { + cleanup(filePath); + } + }); + + it('does not throw when the path is inaccessible (emits warning)', () => { + const badPath = '/nonexistent/deeply/nested/file.json'; + expect(() => { + saveMockData(store, FIXTURES_DIR, badPath); + }).not.toThrow(); + }); +}); + +describe('loadMockData', () => { + let store: MockDataStore; + + beforeEach(() => { + store = new MockDataStore(); + }); + + it('loads pools correctly from a valid file', () => { + const filePath = makeTempPath(); + try { + const users = [{ id: 1, name: 'Alice', email: 'a@b.com' }]; + fs.writeFileSync(filePath, JSON.stringify({ User: users }, null, 2), 'utf-8'); + + const result = loadMockData(store, FIXTURES_DIR, filePath); + + expect(result).toBe(true); + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + expect(store.getPool('User', typeFile)).toEqual(users); + } finally { + cleanup(filePath); + } + }); + + it('loads empty arrays as empty pools (does not regenerate)', () => { + const filePath = makeTempPath(); + try { + fs.writeFileSync(filePath, JSON.stringify({ User: [] }, null, 2), 'utf-8'); + + const result = loadMockData(store, FIXTURES_DIR, filePath); + + expect(result).toBe(true); + const typeFile = path.join(FIXTURES_DIR, 'user.ts'); + expect(store.getPool('User', typeFile)).toEqual([]); + } finally { + cleanup(filePath); + } + }); + + it('returns false when the file is absent', () => { + const result = loadMockData(store, FIXTURES_DIR, '/nonexistent/file.json'); + expect(result).toBe(false); + }); + + it('returns false and emits warning when JSON is invalid (does not crash)', () => { + const filePath = makeTempPath(); + try { + fs.writeFileSync(filePath, '{ invalid json !!!', 'utf-8'); + const result = loadMockData(store, FIXTURES_DIR, filePath); + expect(result).toBe(false); + } finally { + cleanup(filePath); + } + }); + + it('ignores unknown types silently', () => { + const filePath = makeTempPath(); + try { + fs.writeFileSync(filePath, JSON.stringify({ UnknownType: [{ id: 1 }] }, null, 2), 'utf-8'); + const result = loadMockData(store, FIXTURES_DIR, filePath); + // Returns true even if all types were skipped (file was valid JSON) + expect(result).toBe(true); + } finally { + cleanup(filePath); + } + }); +});