mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a6e8c66609
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are Dawarich's own. 946 cases across 30 files take the surface to 100% in shared, 99.7 in server and 99.6 in client, and the gate to roughly 85%. What stays uncovered is thirteen type-narrowing guards and defensive fallbacks the only caller cannot reach, each argued in the file beside it rather than forced with a cast. No source, config or coverage threshold is touched.
203 lines
10 KiB
TypeScript
203 lines
10 KiB
TypeScript
/**
|
|
* DawarichSyncJob (DAWARICH-JOB-001..012): the scheduling shell around the
|
|
* Dawarich poller, built with `new`, collaborators as plain mocks, no Nest
|
|
* container and no timer. DawarichSyncService has its own suite; what is pinned
|
|
* here is only what the job itself decides, and every one of those decisions is
|
|
* the kind that regresses silently rather than loudly:
|
|
*
|
|
* - **the test gate.** `registrar.isEnabled()` is the single reason the suites
|
|
* never grow a live cron. A job that registered past it would have every
|
|
* integration boot start polling whatever Dawarich URL sits in the fixture
|
|
* database.
|
|
* - **the interval clamp.** `dawarich_poll_interval_minutes` is typed by an
|
|
* admin, so '0', '600' and a stray word all have to land on the 15-minute
|
|
* default instead of producing a cron expression the scheduler would reject
|
|
* or, worse, one that fires every minute. The numbers differ from the
|
|
* AirTrail job next door on purpose (floor 5 against its 1, default 15
|
|
* against its 5), which is exactly the kind of difference a later
|
|
* copy-paste flattens: a flight lands once, while a visit detector emits
|
|
* continuously, and a minute-by-minute poll would hammer a self-hosted box
|
|
* for suggestions nobody is waiting on.
|
|
* - **the single read.** The setting is resolved once at bootstrap and the
|
|
* cron registered once, which is what the job's docblock promises. A tick
|
|
* that re-read app_settings would put a query on a timer for a value it
|
|
* could not act on anyway without re-registering.
|
|
* - **the swallow.** `tick` is the cron callback, so an escaping rejection is
|
|
* an unhandled rejection inside the scheduler, not a failed sync. Whatever
|
|
* was thrown (an Error, or the bare value an HTTP layer sometimes rejects
|
|
* with) has to end as one log line and a resolved promise.
|
|
* - **the addon gate's absence here.** It lives per tick inside `runSync`,
|
|
* which is what lets an admin switch the addon off without a restart. A copy
|
|
* of it in the job would freeze that decision at bootstrap, and the bug
|
|
* would look like "the toggle needs a restart", not like a failure.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
|
|
|
import { DawarichSyncJob } from '../../../src/nest/integrations/dawarich-sync.job';
|
|
import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
|
|
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
|
|
|
const SETTING_KEY = 'dawarich_poll_interval_minutes';
|
|
|
|
/**
|
|
* `undefined` for the interval stands for "no app_settings row at all" (the
|
|
* shape the optional chain in the job handles), while '' stands for a row
|
|
* someone blanked, which only the `|| '15'` fallback catches. Two different
|
|
* paths to the same default, and both have shipped as bugs elsewhere.
|
|
*/
|
|
function makeJob(intervalSetting?: string, enabled = true) {
|
|
let onTick: (() => void | Promise<void>) | undefined;
|
|
const registrar = {
|
|
isEnabled: vi.fn(() => enabled),
|
|
register: vi.fn((_name: string, _expression: string, cb: () => void | Promise<void>) => {
|
|
onTick = cb;
|
|
return enabled;
|
|
}),
|
|
unregister: vi.fn(),
|
|
};
|
|
// Shaped like the real `get<T>(sql, ...params)` rather than a bare `vi.fn()`
|
|
// so the stub cannot quietly drift from the signature the job calls; the
|
|
// statement and the key are the whole point of DAWARICH-JOB-002.
|
|
const db = {
|
|
get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
|
|
};
|
|
const sync = {
|
|
runSync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
syncGloballyEnabled: vi.fn(() => false),
|
|
};
|
|
const job = new DawarichSyncJob(
|
|
db as unknown as DatabaseService,
|
|
sync as unknown as DawarichSyncService,
|
|
registrar as unknown as CronRegistrarService,
|
|
);
|
|
return { job, registrar, db, sync, takeTick: () => onTick };
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe('DawarichSyncJob bootstrap', () => {
|
|
it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', () => {
|
|
const { job, registrar } = makeJob('30');
|
|
job.onApplicationBootstrap();
|
|
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/30 * * * *', expect.any(Function));
|
|
expect(logMock.logInfo).toHaveBeenCalledWith('Dawarich sync: scheduled every 30m');
|
|
});
|
|
|
|
it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', () => {
|
|
// Both jobs run the identical statement against app_settings; a copy-paste
|
|
// that kept 'airtrail_poll_interval_minutes' would silently inherit the
|
|
// other integration's cadence and nothing anywhere would fail.
|
|
const { job, db } = makeJob('20');
|
|
job.onApplicationBootstrap();
|
|
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
|
|
});
|
|
|
|
it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', () => {
|
|
for (const [setting, minutes] of [
|
|
[undefined, 15], // no row at all
|
|
['', 15], // row present but blank
|
|
['not-a-number', 15], // parseInt -> NaN
|
|
['0', 15], // below the floor
|
|
['4', 15], // one under the floor
|
|
['60', 15], // one over the ceiling
|
|
['5', 5], // the floor itself is allowed
|
|
['59', 59], // and so is the ceiling
|
|
] as const) {
|
|
vi.clearAllMocks();
|
|
const { job, registrar } = makeJob(setting);
|
|
job.onApplicationBootstrap();
|
|
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', `*/${minutes} * * * *`, expect.any(Function));
|
|
expect(logMock.logInfo).toHaveBeenCalledWith(`Dawarich sync: scheduled every ${minutes}m`);
|
|
}
|
|
});
|
|
|
|
it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', () => {
|
|
// parseInt is lenient and the clamp is what makes that safe: '20 minutes'
|
|
// out of a hand-edited setting is still a usable 20, and everything the
|
|
// clamp dislikes has already gone to the default in the case above.
|
|
const { job, registrar } = makeJob('20 minutes');
|
|
job.onApplicationBootstrap();
|
|
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/20 * * * *', expect.any(Function));
|
|
});
|
|
|
|
it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', () => {
|
|
const { job, registrar, db } = makeJob('15', false);
|
|
job.onApplicationBootstrap();
|
|
expect(registrar.register).not.toHaveBeenCalled();
|
|
expect(db.get).not.toHaveBeenCalled();
|
|
expect(logMock.logInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('DAWARICH-JOB-006: the callback handed to the registrar is the tick, so a fired cron reaches runSync', async () => {
|
|
const { job, sync, takeTick } = makeJob('15');
|
|
job.onApplicationBootstrap();
|
|
const onTick = takeTick();
|
|
expect(onTick).toBeTypeOf('function');
|
|
await onTick?.();
|
|
expect(sync.runSync).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('DAWARICH-JOB-012: resolves the interval once at bootstrap, so a tick neither re-reads the setting nor re-registers', async () => {
|
|
// Reading it per tick would be invisible in behaviour (the cadence would
|
|
// still be right) and would put an app_settings SELECT on a timer forever.
|
|
// Unregistering is the registrar's own business inside register(); the job
|
|
// calling it would leave a window where no cron exists at all.
|
|
const { job, db, registrar, takeTick } = makeJob('15');
|
|
job.onApplicationBootstrap();
|
|
await takeTick()?.();
|
|
expect(db.get).toHaveBeenCalledTimes(1);
|
|
expect(registrar.register).toHaveBeenCalledTimes(1);
|
|
expect(registrar.unregister).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('DawarichSyncJob tick', () => {
|
|
it('DAWARICH-JOB-007: delegates to runSync and stays quiet on success', async () => {
|
|
const { job, sync } = makeJob('15');
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
expect(sync.runSync).toHaveBeenCalledTimes(1);
|
|
expect(logMock.logError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('DAWARICH-JOB-008: an unreachable instance becomes one log line and a resolved promise, never an unhandled rejection in the scheduler', async () => {
|
|
const { job, sync } = makeJob('15');
|
|
sync.runSync.mockRejectedValue(new Error('fetch failed'));
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
expect(logMock.logError).toHaveBeenCalledWith('Dawarich sync tick failed: fetch failed');
|
|
});
|
|
|
|
it('DAWARICH-JOB-009: a non-Error rejection is logged by value instead of as an empty message', async () => {
|
|
// Rejecting with a bare string is what a couple of fetch/abort paths do;
|
|
// reading .message off that would have logged "undefined" and buried the
|
|
// one piece of information the operator needs.
|
|
const { job, sync } = makeJob('15');
|
|
sync.runSync.mockRejectedValue('ECONNREFUSED');
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
expect(logMock.logError).toHaveBeenCalledWith('Dawarich sync tick failed: ECONNREFUSED');
|
|
});
|
|
|
|
it('DAWARICH-JOB-010: a failed tick does not poison the next one', async () => {
|
|
// The job keeps no state across ticks, so the cron recovers on its own
|
|
// after an outage instead of needing a restart.
|
|
const { job, sync } = makeJob('15');
|
|
sync.runSync.mockRejectedValueOnce(new Error('down'));
|
|
await job.tick();
|
|
await job.tick();
|
|
expect(sync.runSync).toHaveBeenCalledTimes(2);
|
|
expect(logMock.logError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('DAWARICH-JOB-011: does not re-check the addon gate itself, leaving the per-tick check inside runSync as the only one', async () => {
|
|
// Copying the gate up here would make an admin toggle need a restart, the
|
|
// exact behaviour the job's docblock says it avoids.
|
|
const { job, sync } = makeJob('15');
|
|
await job.tick();
|
|
expect(sync.runSync).toHaveBeenCalledTimes(1);
|
|
expect(sync.syncGloballyEnabled).not.toHaveBeenCalled();
|
|
});
|
|
});
|