mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
124 lines
5.8 KiB
TypeScript
124 lines
5.8 KiB
TypeScript
/**
|
|
* Doc-sync webhook request-context ratchet (Plan 3h Task 5, R9).
|
|
*
|
|
* `DocSyncWebhookController#schedule()`'s `setTimeout` callback runs fully
|
|
* detached from the HTTP request that triggered it: `nudge()` already
|
|
* answered `{received:true}` before the debounce window
|
|
* (`WEBHOOK_NUDGE_DEBOUNCE_SECONDS`) elapses, so whatever request-scoped
|
|
* `EntityManager` fork the triggering request held is long gone by the time
|
|
* the timer body runs. Per 3f's own R3 precedent (measured: `AsyncLocalStorage`
|
|
* survives a detached chain intact in this codebase today), the timer body
|
|
* 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.
|
|
*/
|
|
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');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
SESSION_DURATION: '24h',
|
|
SESSION_DURATION_MS: 86400000,
|
|
SESSION_DURATION_SECONDS: 86400,
|
|
DEFAULT_LANGUAGE: 'en',
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
|
|
|
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';
|
|
|
|
describe('DocSyncWebhookController#schedule() runs its detached timer body inside its own request context', () => {
|
|
let app: INestApplication;
|
|
let server: Server;
|
|
let token: string;
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
server = app.getHttpServer();
|
|
|
|
// 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.
|
|
testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'");
|
|
testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'");
|
|
|
|
const owner = createUser(testDb, { username: 'docsync-webhook-ctx', email: 'docsync-webhook-ctx@test.local' }).user;
|
|
const trip = createTrip(testDb, owner.id, { title: 'Japan' });
|
|
const connInfo = testDb
|
|
.prepare(
|
|
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
|
|
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
|
|
)
|
|
.run(trip.id, owner.id);
|
|
token = 'webhook-ctx-ratchet-token';
|
|
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, ?, ?)`,
|
|
)
|
|
.run(trip.id, connInfo.lastInsertRowid, token, owner.id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
testDb.close();
|
|
});
|
|
|
|
it(
|
|
'WEBHOOK-CTX-001: nudging from a bare (non-request) context never logs cannotUseGlobalContext once the 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 });
|
|
|
|
// Let the real debounce timer fire, then drain the microtasks its
|
|
// async body queues (real timers here — the detached body is a
|
|
// genuine `setTimeout`, not something fake timers can fast-forward
|
|
// through a real HTTP-free integration boot safely).
|
|
await new Promise((resolve) => setTimeout(resolve, (WEBHOOK_NUDGE_DEBOUNCE_SECONDS + 2) * 1000));
|
|
|
|
const suspicious = errSpy.mock.calls
|
|
.map((args) => args.map(String).join(' '))
|
|
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
|
expect(suspicious).toEqual([]);
|
|
} finally {
|
|
errSpy.mockRestore();
|
|
}
|
|
},
|
|
20_000,
|
|
);
|
|
});
|