test(coverage): add regression tests for issues #192, #206, #204

Additive test coverage across server, editor-ext, client and mcp.

#192 — AiChatService.stream integration (Section 3, against real Postgres):
- new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real
  streamText through a seeded ai/test MockLanguageModelV3 and a real Node
  ServerResponse, covering: onError persists an assistant error record
  (status 'error' + partial answer + provider cause in metadata); external MCP
  client closed exactly once on BOTH onFinish and onError; anti-tamper —
  history is rebuilt from the DB transcript, not from body.messages.

#206 — red-team findings (most already fixed+tested in #212):
- mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that
  pageBreak / transclusionReference / mention are silently dropped on Markdown
  export (characterization + it.fails for the desired survive-export contract).
- persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing
  documenting that a momentarily-empty live doc overwrites non-empty content
  (left unfixed — a store-side empty-guard is a behaviour change).

#204 — test-strategy plan, highest-priority subset:
- Phase 1: mcp-clients.lease.spec.ts covers the external MCP client
  lease/refcount/eviction lifecycle (leak / premature-close / double-close).
- Phase 2 data-integrity pure functions: editor-ext table-utils
  (transpose/moveRow/convert round-trip) and math tokenizer false-positive
  guard; client emoji-menu (+ it.fails for the unguarded localStorage
  JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/
  pageBreak markdown data-loss + footnote-diff; server export
  getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-27 06:15:55 +03:00
parent 22852be2e2
commit 2d36641f28
13 changed files with 1438 additions and 1 deletions
@@ -0,0 +1,157 @@
import { McpClientsService } from './mcp-clients.service';
/**
* #204 (Phase 1, highest-value MCP gap) — external MCP client lease / refcount /
* eviction lifecycle.
*
* `toolsFor` hands the streaming turn a release handle; the real transports must
* be closed EXACTLY once and only when (a) the cache entry has been evicted AND
* (b) no turn still leases it. The bugs this guards against:
* - leak: an evicted entry whose clients are never closed (refCount stuck > 0);
* - premature close: a TTL/CRUD eviction closing a client a turn is still
* executing tool calls against;
* - double close: a release handle closing the same client more than once.
*
* The private `buildEntry` is stubbed so no real network/MCP connection happens;
* we drive only the lease bookkeeping in `toolsFor` / `release` / `evict` /
* `invalidate`, which is the untested surface.
*/
describe('McpClientsService lease/refcount/eviction', () => {
type FakeClient = { tools: () => Promise<any>; close: jest.Mock };
function fakeClient(): FakeClient {
return {
tools: async () => ({}),
close: jest.fn().mockResolvedValue(undefined),
};
}
// Minimal CacheEntry the service's lease logic operates on.
function makeEntry(clients: FakeClient[]) {
const timer = setTimeout(() => {}, 60_000);
timer.unref?.();
return {
tools: {},
clients,
outcomes: [],
instructions: [],
expiresAt: Date.now() + 60_000,
refCount: 0,
evicted: false,
closed: false,
timer,
} as any;
}
let service: McpClientsService;
beforeEach(() => {
service = new McpClientsService({} as any, {} as any);
});
function stubBuild(entry: any) {
jest.spyOn(service as any, 'buildEntry').mockResolvedValue(entry);
}
it('leases on toolsFor and keeps the client warm (no close) on release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-1');
expect(entry.refCount).toBe(1);
await lease.clients[0].close();
// Released but NOT evicted: the cached entry stays warm for reuse, so the
// transport must NOT be closed yet.
expect(entry.refCount).toBe(0);
expect(client.close).not.toHaveBeenCalled();
});
it('defers close when an entry is evicted while still leased, then closes once on release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-2');
(service as any).evict(entry);
// Evicted under an active lease: close is deferred to the last release.
expect(entry.evicted).toBe(true);
expect(client.close).not.toHaveBeenCalled();
await lease.clients[0].close();
expect(client.close).toHaveBeenCalledTimes(1);
expect(entry.closed).toBe(true);
});
it('shares one entry across concurrent leases; closes only after the LAST release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease1 = await service.toolsFor('ws-3');
const lease2 = await service.toolsFor('ws-3');
expect(entry.refCount).toBe(2);
(service as any).evict(entry);
await lease1.clients[0].close();
// One lease remains: a stream could still be running — must stay open.
expect(entry.refCount).toBe(1);
expect(client.close).not.toHaveBeenCalled();
await lease2.clients[0].close();
expect(entry.refCount).toBe(0);
expect(client.close).toHaveBeenCalledTimes(1);
});
it('release is idempotent: closing the same handle twice decrements once and closes once', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-4');
(service as any).evict(entry);
await lease.clients[0].close();
await lease.clients[0].close();
expect(entry.refCount).toBe(0); // not -1
expect(client.close).toHaveBeenCalledTimes(1);
});
it('evicting an unleased entry closes its clients immediately', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const built = await (service as any).getOrBuildEntry('ws-5');
expect(built.refCount).toBe(0);
(service as any).evict(entry);
expect(client.close).toHaveBeenCalledTimes(1);
expect(entry.closed).toBe(true);
});
it('invalidate (TTL/CRUD) does NOT close a client that a turn still leases', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-6');
expect(entry.refCount).toBe(1);
service.invalidate('ws-6');
// invalidate evicts asynchronously once the build promise resolves.
await Promise.resolve();
await Promise.resolve();
expect(entry.evicted).toBe(true);
// Still leased: the mid-turn eviction must not pull the transport.
expect(client.close).not.toHaveBeenCalled();
await lease.clients[0].close();
expect(client.close).toHaveBeenCalledTimes(1);
});
});