fix(nesting-guard): export dataDir from src/config/paths; harden fallback to platform-paths (DC-077) [glm-grade=B]
Pre-fix, every dashcaddy-api container startup logged:
[nesting-guard] Skipped: The "path" argument must be of type string. Received undefined
because src/utilities/nesting-guard.js does require('../config/paths') and
calls paths.dataDir — but src/config/paths.js imported platformPaths and
only re-exported its specific files (SERVICES_FILE, CONFIG_FILE, etc);
dataDir was never re-exported, so paths.dataDir was undefined.
Result: path.join(undefined, 'data') threw TypeError, the outer try/catch
swallowed it, and the entire nesting-guard became a silent no-op. The
cleanup that prevents recursive data/data/data/... directory duplicates
never ran on any startup. Bug class is 'silent functional no-op' (same
family as DC-056 AggregateError visibility).
(1) src/config/paths.js (+11): re-export dataDir as
SERVICES_DIR-derived (with platformPaths.dataDir fallback). dataDir is
the dirname of SERVICES_FILE in container (env override wins), which
equals /app/data — same value platform-paths.dataDir computes for the
default config. Either path is fine; SERVICES_DIR is preferred because it
respects env-override.
(2) src/utilities/nesting-guard.js (+13/-2): defensive fallback to
require('../../platform-paths').dataDir if paths.dataDir is missing
(any future export-shape drift or older caller). Explicit skip-warn
instead of silent catch when both paths fail.
(3) __tests__/nesting-guard.test.js (NEW, 112 lines, 4/4 passing):
isolates module cache per test, exercises (a) cleanup when nested
data/data exists, (b) no-op when clean, (c) dataDir export contract,
(d) dataDir === dirname(SERVICES_FILE) under env override. No jest.doMock
leaks across tests (verified via 4-call probe sequence).
Verified: 4/4 tests passing. Full repo suite: 100/104 suites / 2335/2335
tests passing (4 pre-existing failures in __tests__/billing/* are
unrelated module-resolution issues in src/billing/invoice.js, confirmed
unaffected by this change via stash+rerun).
GLM-5.3 round 1: B (ship, one polish nit — trailing newline on test
file, folded in same commit per multi-round-fix-first protocol).
Deploy plan: container rebuild + atomic swap via /opt/dashcaddy/start.sh
on DNS2; live-verify status.sami=200, dashcaddy-api=Up+healthy, and
absence of [nesting-guard] Skipped log line in container logs.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Nesting-guard tests — DC-077 (data/data recursive duplicate cleanup)
|
||||
*
|
||||
* The guard runs at app startup. Pre-fix, `src/config/paths.js` did NOT
|
||||
* re-export `dataDir`, so `paths.dataDir` resolved to `undefined`. The
|
||||
* outer try/catch swallowed the resulting `TypeError [ERR_INVALID_ARG_TYPE]`
|
||||
* and the entire guard became a silent no-op — every startup logged
|
||||
* `[nesting-guard] Skipped: The "path" argument must be of type string.
|
||||
* Received undefined`. Post-fix, paths.js exports `dataDir` and the guard
|
||||
* falls back to platform-paths directly if `paths.dataDir` is missing.
|
||||
*
|
||||
* Tests use jest.isolateModules() for clean module-cache isolation.
|
||||
* jest.doMock is intentionally avoided — it persists across tests in a
|
||||
* describe and is the root cause of subtle flakes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('nesting-guard (DC-077)', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeTmpTree() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'nest-guard-'));
|
||||
}
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
it('removes a recursive data/data duplicate when present', () => {
|
||||
const tmp = makeTmpTree();
|
||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||
writeJson(path.join(tmp, 'data', 'config.json'), { x: 1 });
|
||||
writeJson(path.join(tmp, 'data', 'services.json'), []);
|
||||
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let cleanupLog = '';
|
||||
let warnLog = '';
|
||||
jest.isolateModules(() => {
|
||||
const guard = require('../src/utilities/nesting-guard');
|
||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||
guard();
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(tmp, 'data'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||
expect(cleanupLog).toMatch(/Removing recursive data nesting|Recursive nesting removed/);
|
||||
expect(warnLog).not.toMatch(/Skipped/);
|
||||
});
|
||||
|
||||
it('does nothing when no nested data/data directory exists', () => {
|
||||
const tmp = makeTmpTree();
|
||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let cleanupLog = '';
|
||||
let warnLog = '';
|
||||
jest.isolateModules(() => {
|
||||
const guard = require('../src/utilities/nesting-guard');
|
||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||
guard();
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||
expect(warnLog).not.toMatch(/Skipped/);
|
||||
expect(cleanupLog).not.toMatch(/Removing recursive data nesting/);
|
||||
});
|
||||
|
||||
it('src/config/paths exports dataDir as a non-empty string', () => {
|
||||
let dataDir;
|
||||
jest.isolateModules(() => {
|
||||
const paths = require('../src/config/paths');
|
||||
dataDir = paths.dataDir;
|
||||
});
|
||||
expect(typeof dataDir).toBe('string');
|
||||
expect(dataDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('src/config/paths.dataDir equals dirname(SERVICES_FILE) when SERVICES_FILE env is set', () => {
|
||||
const tmp = makeTmpTree();
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let servicesFile, dataDir;
|
||||
jest.isolateModules(() => {
|
||||
const paths = require('../src/config/paths');
|
||||
servicesFile = paths.SERVICES_FILE;
|
||||
dataDir = paths.dataDir;
|
||||
});
|
||||
|
||||
expect(dataDir).toBe(path.dirname(servicesFile));
|
||||
expect(dataDir).toBe(tmp);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user