mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations
This commit is contained in:
@@ -11,21 +11,32 @@
|
||||
* now forks its OWN fresh request context via `withRequestContext` — insurance,
|
||||
* not a fix for an observed failure.
|
||||
*
|
||||
* This test drives the REAL `DocSyncWebhookController.nudge()` (not a
|
||||
* hand-built double) through a real HTTP request against a compiled
|
||||
* `buildApp()` boot — `nudge()` itself needs the per-request `EntityManager`
|
||||
* fork Nest's own middleware provides, so only the schedule()`'s DETACHED
|
||||
* TIMER body (which runs from a genuinely bare context, after the response
|
||||
* has already gone out) is what this test's assertion is about — and asserts
|
||||
* that body never logs `cannotUseGlobalContext` — the same
|
||||
* regression class `boot-sweeps-request-context.test.ts` guards for the seven
|
||||
* cron boot sweeps and `storage-health-notifier-request-context.test.ts`
|
||||
* guards for Plan 3f's own R3 listener.
|
||||
* Plan 3h Task 7 review, M3: the original version of this test drove the
|
||||
* REAL `DocSyncWebhookController.nudge()` through a real HTTP request, on the
|
||||
* theory that only the timer body — not `nudge()` itself — was the "bare
|
||||
* context" under test. That theory was wrong in a way that made the ratchet
|
||||
* non-load-bearing: the HTTP request driving `nudge()` gives Nest's own
|
||||
* middleware an `EntityManager` fork, and `AsyncLocalStorage` carries that
|
||||
* fork into the `setTimeout` callback regardless of whether `schedule()`
|
||||
* wraps it in its own `withRequestContext` — so unwrapping it left the test
|
||||
* green (proven; recorded in the review's mutation log as MD3). The request
|
||||
* was never gone by the time the timer fired; it just felt that way because
|
||||
* the response had already been sent.
|
||||
*
|
||||
* The fix: call the controller's `schedule()` directly from the test's own
|
||||
* top-level async function body, which sits in NO `AsyncLocalStorage`
|
||||
* context at all — a genuinely bare one. `schedule()` itself needs no
|
||||
* `EntityManager` (it only touches the in-memory `pending` map and
|
||||
* `setTimeout`), so this call is safe outside any request; the `reload`
|
||||
* callback it schedules (`() => config.getLink(linkId)`) is what needs the
|
||||
* fork, and it only runs once the timer fires, inside `schedule()`'s own
|
||||
* `withRequestContext` — exactly the wrap under test. The link id comes from
|
||||
* a raw `testDb` read (no ORM, no context needed) rather than through
|
||||
* `nudge()`/`getLinkByToken`, so nothing before the `schedule()` call ever
|
||||
* touches the EntityManager.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import type { Server } from 'http';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -46,19 +57,23 @@ import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from '../../src/nest/doc-sync/doc-sync.constants';
|
||||
import { DocSyncWebhookController } from '../../src/nest/doc-sync/doc-sync-webhook.controller';
|
||||
import { DocSyncConfigService } from '../../src/nest/doc-sync/doc-sync-config.service';
|
||||
|
||||
describe('DocSyncWebhookController#schedule() runs its detached timer body inside its own request context', () => {
|
||||
let app: INestApplication;
|
||||
let server: Server;
|
||||
let token: string;
|
||||
let controller: DocSyncWebhookController;
|
||||
let config: DocSyncConfigService;
|
||||
let linkId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
server = app.getHttpServer();
|
||||
controller = app.get(DocSyncWebhookController);
|
||||
config = app.get(DocSyncConfigService);
|
||||
|
||||
// The addon gate and the provider both have to be genuinely on: nudge()
|
||||
// returns before ever scheduling anything otherwise (isSwitchedOff's own
|
||||
// early-out), and this ratchet needs schedule() to actually fire.
|
||||
// The addon gate and the provider both have to be genuinely on: schedule()'s
|
||||
// own reload callback (`syncIsOn`) skips the run otherwise, before ever
|
||||
// reaching the assertion below.
|
||||
testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'");
|
||||
testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'");
|
||||
|
||||
@@ -70,15 +85,17 @@ describe('DocSyncWebhookController#schedule() runs its detached timer body insid
|
||||
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
|
||||
)
|
||||
.run(trip.id, owner.id);
|
||||
token = 'webhook-ctx-ratchet-token';
|
||||
testDb
|
||||
const linkInfo = testDb
|
||||
.prepare(
|
||||
`INSERT INTO trip_document_links
|
||||
(trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy,
|
||||
conflict_policy, sync_enabled, webhook_token, created_by)
|
||||
VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?, ?)`,
|
||||
VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, 'webhook-ctx-ratchet-token', ?)`,
|
||||
)
|
||||
.run(trip.id, connInfo.lastInsertRowid, token, owner.id);
|
||||
.run(trip.id, connInfo.lastInsertRowid, owner.id);
|
||||
// A plain better-sqlite3 read — no ORM, no request context — so nothing
|
||||
// before the `schedule()` call below ever touches the EntityManager.
|
||||
linkId = Number(linkInfo.lastInsertRowid);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -87,22 +104,16 @@ describe('DocSyncWebhookController#schedule() runs its detached timer body insid
|
||||
});
|
||||
|
||||
it(
|
||||
'WEBHOOK-CTX-001: nudging from a bare (non-request) context never logs cannotUseGlobalContext once the debounce timer fires',
|
||||
'WEBHOOK-CTX-001: schedule(), called from a genuinely bare (non-request) context, never logs cannotUseGlobalContext once its debounce timer fires',
|
||||
async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
// A REAL HTTP request through the compiled app — not a hand-built
|
||||
// double, and not a direct method call either: `nudge()` itself
|
||||
// needs the per-request `EntityManager` fork Nest's own middleware
|
||||
// provides (its own `getLinkByToken` read would otherwise throw
|
||||
// `cannotUseGlobalContext` before ever reaching `schedule()` — this
|
||||
// is what a direct `app.get(...).nudge(...)` call, tried first,
|
||||
// actually surfaced: confirmation this test needs the real request
|
||||
// path, not a shortcut around it). Only the DETACHED TIMER body
|
||||
// that fires after this response is the "bare context" under test.
|
||||
const res = await request(server).post(`/api/docsync/webhook/${token}`).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ received: true });
|
||||
// Calling the controller's own private `schedule()` directly, bracket
|
||||
// notation only (no `any`/`as unknown as`): this test function body is
|
||||
// not inside ANY AsyncLocalStorage context, unlike a real HTTP request
|
||||
// through Nest's middleware — so the timer this starts is the real
|
||||
// thing the docstring above describes, not one riding a caller's fork.
|
||||
controller['schedule'](linkId, () => config.getLink(linkId));
|
||||
|
||||
// Let the real debounce timer fire, then drain the microtasks its
|
||||
// async body queues (real timers here — the detached body is a
|
||||
|
||||
Reference in New Issue
Block a user