2026-09-10 19:10:14 -05:00
|
|
|
/**
|
2026-09-14 15:53:25 +02:00
|
|
|
* @fileoverview GET /api/settings is a plain read and must NEVER write
|
|
|
|
|
* settings.json.
|
2026-09-10 19:10:14 -05:00
|
|
|
*
|
2026-09-14 15:53:25 +02:00
|
|
|
* PR #361 once made it reconcile `showPlanUsageLimits` on first read: if the
|
|
|
|
|
* key was absent, the route persisted `true`. But readJsonConfig() answers
|
|
|
|
|
* `{}` for ANY read failure (parse error, EACCES, EMFILE, a read landing inside
|
|
|
|
|
* PUT's non-atomic write), not only ENOENT, and every page load calls this
|
|
|
|
|
* route, so one unlucky read replaced the whole settings file with a one-key
|
|
|
|
|
* file. The default now lives in the readers instead: an absent key means ON
|
|
|
|
|
* to readPlanUsageTelemetryEnabled() (pinned in test/hooks-config.test.ts) and
|
|
|
|
|
* to planUsageChipEnabled() on the client.
|
2026-09-10 19:10:14 -05:00
|
|
|
*
|
2026-09-14 15:53:25 +02:00
|
|
|
* Uses app.inject() with a mocked filesystem. Port: N/A.
|
2026-09-10 19:10:14 -05:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
|
|
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
|
|
|
|
|
|
|
|
|
// vi.mock factories are hoisted above module-level consts, so the mutable
|
2026-09-14 15:53:25 +02:00
|
|
|
// "disk" fixture has to be built inside vi.hoisted().
|
|
|
|
|
const { state, writeFile } = vi.hoisted(() => ({
|
|
|
|
|
// `raw` is what readFile returns; `failWith` makes it throw with that code.
|
|
|
|
|
state: { raw: '{}', failWith: null as string | null },
|
|
|
|
|
writeFile: vi.fn(async () => {}),
|
2026-09-10 19:10:14 -05:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
|
|
|
default: {
|
|
|
|
|
readFile: vi.fn(async () => {
|
2026-09-14 15:53:25 +02:00
|
|
|
if (state.failWith) {
|
|
|
|
|
const err = new Error(state.failWith) as NodeJS.ErrnoException;
|
|
|
|
|
err.code = state.failWith;
|
2026-09-10 19:10:14 -05:00
|
|
|
throw err;
|
|
|
|
|
}
|
2026-09-14 15:53:25 +02:00
|
|
|
return state.raw;
|
2026-09-10 19:10:14 -05:00
|
|
|
}),
|
2026-09-14 15:53:25 +02:00
|
|
|
writeFile,
|
2026-09-10 19:10:14 -05:00
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import('node:fs')>();
|
|
|
|
|
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
describe('GET /api/settings never writes settings.json', () => {
|
2026-09-10 19:10:14 -05:00
|
|
|
let harness: RouteTestHarness;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
2026-09-14 15:53:25 +02:00
|
|
|
state.raw = '{}';
|
|
|
|
|
state.failWith = null;
|
|
|
|
|
writeFile.mockClear();
|
2026-09-10 19:10:14 -05:00
|
|
|
harness = await createRouteTestHarness(registerSystemRoutes);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await harness.app.close();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
it('returns the file content unchanged when showPlanUsageLimits is absent, and writes nothing', async () => {
|
|
|
|
|
state.raw = JSON.stringify({ someOtherSetting: true });
|
2026-09-10 19:10:14 -05:00
|
|
|
|
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
2026-09-14 15:53:25 +02:00
|
|
|
expect(res.json()).toEqual({ someOtherSetting: true });
|
|
|
|
|
expect(writeFile).not.toHaveBeenCalled();
|
2026-09-10 19:10:14 -05:00
|
|
|
});
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
it('answers {} for a missing settings.json without creating one', async () => {
|
|
|
|
|
state.failWith = 'ENOENT';
|
2026-09-10 19:10:14 -05:00
|
|
|
|
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
2026-09-14 15:53:25 +02:00
|
|
|
expect(res.json()).toEqual({});
|
|
|
|
|
expect(writeFile).not.toHaveBeenCalled();
|
2026-09-10 19:10:14 -05:00
|
|
|
});
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
it('leaves an unreadable settings.json alone (EACCES is not "absent")', async () => {
|
|
|
|
|
state.failWith = 'EACCES';
|
|
|
|
|
const quiet = vi.spyOn(console, 'error').mockImplementation(() => {});
|
2026-09-10 19:10:14 -05:00
|
|
|
|
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
quiet.mockRestore();
|
2026-09-10 19:10:14 -05:00
|
|
|
expect(res.statusCode).toBe(200);
|
2026-09-14 15:53:25 +02:00
|
|
|
expect(res.json()).toEqual({});
|
|
|
|
|
expect(writeFile).not.toHaveBeenCalled();
|
2026-09-10 19:10:14 -05:00
|
|
|
});
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
it('leaves a garbage settings.json alone (a parse error is not "absent")', async () => {
|
|
|
|
|
state.raw = '{ "showPlanUsageLimits": tru';
|
|
|
|
|
const quiet = vi.spyOn(console, 'error').mockImplementation(() => {});
|
2026-09-10 19:10:14 -05:00
|
|
|
|
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
|
|
|
|
2026-09-14 15:53:25 +02:00
|
|
|
quiet.mockRestore();
|
2026-09-10 19:10:14 -05:00
|
|
|
expect(res.statusCode).toBe(200);
|
2026-09-14 15:53:25 +02:00
|
|
|
expect(res.json()).toEqual({});
|
|
|
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('passes an explicit value through either way', async () => {
|
|
|
|
|
for (const value of [true, false]) {
|
|
|
|
|
state.raw = JSON.stringify({ showPlanUsageLimits: value });
|
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
|
|
|
expect(res.json().showPlanUsageLimits).toBe(value);
|
|
|
|
|
}
|
|
|
|
|
expect(writeFile).not.toHaveBeenCalled();
|
2026-09-10 19:10:14 -05:00
|
|
|
});
|
|
|
|
|
});
|