test(#244): Part B backlog — editor-ext/mcp/client/server unit+contract tests + findBreadcrumbPath mutation fix #257
278
apps/server/src/collaboration/yjs.util.spec.ts
Normal file
278
apps/server/src/collaboration/yjs.util.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import * as Y from 'yjs';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import {
|
||||
initProseMirrorDoc,
|
||||
absolutePositionToRelativePosition,
|
||||
prosemirrorJSONToYDoc,
|
||||
} from '@tiptap/y-tiptap';
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
import {
|
||||
setYjsMark,
|
||||
removeYjsMarkByAttribute,
|
||||
updateYjsMarkAttribute,
|
||||
type YjsSelection,
|
||||
} from './yjs.util';
|
||||
|
||||
/**
|
||||
* Unit tests for the server-side Yjs mark helpers used by the collaboration
|
||||
* handler to set/resolve/delete comment marks directly on the shared Y.Doc
|
||||
* (collaboration.handler.ts: setCommentMark / resolveCommentMark).
|
||||
*
|
||||
* The fragment shape mirrors production exactly: a `default` XmlFragment whose
|
||||
* children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark
|
||||
* the selection is a pair of Yjs RelativePosition JSONs (what the client sends);
|
||||
* we synthesize them from known ProseMirror absolute positions via
|
||||
* absolutePositionToRelativePosition so the marked range is deterministic.
|
||||
*/
|
||||
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
|
||||
// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses
|
||||
// via TiptapTransformer) and return the doc + its `default` fragment.
|
||||
function buildFromPm(pmJson: unknown) {
|
||||
const ydoc = prosemirrorJSONToYDoc(
|
||||
schema,
|
||||
pmJson as never,
|
||||
'default',
|
||||
) as unknown as Y.Doc;
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
return { ydoc, fragment };
|
||||
}
|
||||
|
||||
// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror
|
||||
// absolute positions in `fragment`.
|
||||
function selectionFor(
|
||||
fragment: Y.XmlFragment,
|
||||
anchorPos: number,
|
||||
headPos: number,
|
||||
): YjsSelection {
|
||||
const { mapping } = initProseMirrorDoc(fragment, schema);
|
||||
const anchor = absolutePositionToRelativePosition(
|
||||
anchorPos,
|
||||
fragment as never,
|
||||
mapping,
|
||||
);
|
||||
const head = absolutePositionToRelativePosition(
|
||||
headPos,
|
||||
fragment as never,
|
||||
mapping,
|
||||
);
|
||||
return {
|
||||
anchor: Y.relativePositionToJSON(anchor),
|
||||
head: Y.relativePositionToJSON(head),
|
||||
};
|
||||
}
|
||||
|
||||
// The XmlText run of the i-th top-level paragraph.
|
||||
function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText {
|
||||
const para = fragment.get(index) as Y.XmlElement;
|
||||
return para.get(0) as Y.XmlText;
|
||||
}
|
||||
|
||||
// --- raw fragment builder for the remove/update tests (no schema needed) ---
|
||||
//
|
||||
// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and
|
||||
// call item.format(); they never touch the ProseMirror schema. Build the runs
|
||||
// directly so we control which segment carries which comment attrs.
|
||||
function buildWithComments(
|
||||
segments: Array<{
|
||||
text: string;
|
||||
comment?: { commentId: string; resolved: boolean };
|
||||
}>,
|
||||
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
let offset = 0;
|
||||
for (const seg of segments) {
|
||||
text.insert(offset, seg.text);
|
||||
if (seg.comment) {
|
||||
text.format(offset, seg.text.length, { comment: seg.comment });
|
||||
}
|
||||
offset += seg.text.length;
|
||||
}
|
||||
return { fragment, text };
|
||||
}
|
||||
|
||||
describe('setYjsMark', () => {
|
||||
it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||
],
|
||||
});
|
||||
// PM pos 1 = start of the paragraph text; pos 6 = just after "Hello".
|
||||
const sel = selectionFor(fragment, 1, 6);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c1',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
// The run splits: "Hello" carries the comment mark, " world" stays clean.
|
||||
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Hello',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false } },
|
||||
},
|
||||
{ insert: ' world' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes a reversed selection (head before anchor) to the same range', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
|
||||
],
|
||||
});
|
||||
// anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello".
|
||||
const sel = selectionFor(fragment, 6, 1);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c2',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
expect(paragraphText(fragment).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Hello',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
{ insert: ' world' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks across two paragraphs (range spans an element boundary)', () => {
|
||||
const { ydoc, fragment } = buildFromPm({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] },
|
||||
],
|
||||
});
|
||||
// PM positions: "aaa" = 1..4; the </p><p> boundary consumes pos 4 and 5, so
|
||||
// "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos
|
||||
// 8 (after the second "b").
|
||||
const sel = selectionFor(fragment, 2, 8);
|
||||
|
||||
setYjsMark(ydoc as never, fragment, sel, 'comment', {
|
||||
commentId: 'c3',
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
// First paragraph: "a" clean, "aa" marked.
|
||||
expect(paragraphText(fragment, 0).toDelta()).toEqual([
|
||||
{ insert: 'a' },
|
||||
{
|
||||
insert: 'aa',
|
||||
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||
},
|
||||
]);
|
||||
// Second paragraph: "bb" marked, "b" clean.
|
||||
expect(paragraphText(fragment, 1).toDelta()).toEqual([
|
||||
{
|
||||
insert: 'bb',
|
||||
attributes: { comment: { commentId: 'c3', resolved: false } },
|
||||
},
|
||||
{ insert: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeYjsMarkByAttribute', () => {
|
||||
it('removes only the run whose attribute value matches, leaving others', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||
]);
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||
|
||||
// c1's run loses the mark; c2's run is untouched.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'AAA' },
|
||||
{
|
||||
insert: 'BBB',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when no run carries the requested value (no-match branch)', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist');
|
||||
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
it('leaves a different mark type alone', () => {
|
||||
// A run carrying only `bold` must survive a comment removal pass.
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, 'XYZ');
|
||||
text.format(0, 3, { bold: true });
|
||||
|
||||
removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1');
|
||||
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'XYZ', attributes: { bold: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateYjsMarkAttribute', () => {
|
||||
it('merges new attributes into the matching run, preserving the rest', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
{ text: 'BBB', comment: { commentId: 'c2', resolved: false } },
|
||||
]);
|
||||
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: 'c1' },
|
||||
{ resolved: true },
|
||||
);
|
||||
|
||||
// c1's run flips resolved=true (commentId preserved via merge); c2 untouched.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'AAA',
|
||||
attributes: { comment: { commentId: 'c1', resolved: true } },
|
||||
},
|
||||
{
|
||||
insert: 'BBB',
|
||||
attributes: { comment: { commentId: 'c2', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when no run matches (no-match branch)', () => {
|
||||
const { fragment, text } = buildWithComments([
|
||||
{ text: 'AAA', comment: { commentId: 'c1', resolved: false } },
|
||||
]);
|
||||
const before = text.toDelta();
|
||||
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: 'nope' },
|
||||
{ resolved: true },
|
||||
);
|
||||
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Unit tests for the two security-critical surfaces of McpClientsService that the
|
||||
* sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover:
|
||||
*
|
||||
* 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure
|
||||
* (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER
|
||||
* log the blob: it returns `undefined` so the connect proceeds WITHOUT the
|
||||
* now-unreadable auth headers (which then 401s and the server is skipped),
|
||||
* rather than crashing the whole turn.
|
||||
*
|
||||
* 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the
|
||||
* per-request DNS-rebinding guard. A blocked host (private/loopback/metadata
|
||||
* IP literal, or an unparseable URL) must REJECT before any socket is opened;
|
||||
* a public host is allowed through to the real `fetch` with the pinned
|
||||
* dispatcher attached.
|
||||
*
|
||||
* No network and no DB: the repo + secretBox deps are stubbed, and global `fetch`
|
||||
* is mocked for the single allow-path assertion.
|
||||
*/
|
||||
|
||||
// Build the service with a SecretBoxService stub whose decryptSecret is supplied
|
||||
// per-test. The repo dep is unused by the methods under test.
|
||||
function buildService(decryptSecret: (blob: string) => string) {
|
||||
const secretBox = { decryptSecret: jest.fn(decryptSecret) };
|
||||
const service = new McpClientsService({} as never, secretBox as never);
|
||||
return { service, secretBox };
|
||||
}
|
||||
|
||||
describe('McpClientsService.decryptHeaders', () => {
|
||||
// Reach the private method via the as-any pattern common in these NestJS specs.
|
||||
const callDecrypt = (
|
||||
service: McpClientsService,
|
||||
blob: string | null,
|
||||
): Record<string, string> | undefined =>
|
||||
(
|
||||
service as unknown as {
|
||||
decryptHeaders: (b: string | null) => Record<string, string> | undefined;
|
||||
}
|
||||
).decryptHeaders(blob);
|
||||
|
||||
it('returns undefined for a null blob without decrypting', () => {
|
||||
const { service, secretBox } = buildService(() => '{}');
|
||||
expect(callDecrypt(service, null)).toBeUndefined();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decrypts a valid blob and keeps only string-valued headers', () => {
|
||||
const { service } = buildService(() =>
|
||||
JSON.stringify({
|
||||
Authorization: 'Bearer abc',
|
||||
'X-Api-Key': 'k',
|
||||
// Non-string values must be dropped, not coerced.
|
||||
count: 5,
|
||||
flag: true,
|
||||
nested: { a: 1 },
|
||||
}),
|
||||
);
|
||||
expect(callDecrypt(service, 'cipher')).toEqual({
|
||||
Authorization: 'Bearer abc',
|
||||
'X-Api-Key': 'k',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the decrypted object has no string headers', () => {
|
||||
const { service } = buildService(() => JSON.stringify({ count: 5 }));
|
||||
// No usable headers -> undefined (connect with no auth header), not {}.
|
||||
expect(callDecrypt(service, 'cipher')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FAILS OPEN: a decrypt error returns undefined instead of throwing', () => {
|
||||
const { service } = buildService(() => {
|
||||
throw new Error('Failed to decrypt secret — APP_SECRET may have changed');
|
||||
});
|
||||
const warnSpy = jest
|
||||
.spyOn(
|
||||
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
||||
.logger,
|
||||
'warn',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
let result: unknown;
|
||||
expect(() => {
|
||||
result = callDecrypt(service, 'tampered-blob');
|
||||
}).not.toThrow();
|
||||
expect(result).toBeUndefined();
|
||||
// It warns (so ops sees degradation) but never logs the blob itself.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain('tampered-blob');
|
||||
});
|
||||
|
||||
it('FAILS OPEN: malformed JSON (decrypts to non-JSON) returns undefined', () => {
|
||||
const { service } = buildService(() => 'not-json{');
|
||||
jest
|
||||
.spyOn(
|
||||
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
||||
.logger,
|
||||
'warn',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
expect(callDecrypt(service, 'cipher')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => {
|
||||
// The bound guardedFetch closure lives on the instance as a private field.
|
||||
const guardedFetchOf = (service: McpClientsService) =>
|
||||
(service as unknown as { guardedFetch: typeof fetch }).guardedFetch;
|
||||
|
||||
let fetchSpy: jest.SpiedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Any reachable real fetch would be a network call; assert per-test that the
|
||||
// blocked paths never reach it, and stub a Response for the allow path.
|
||||
fetchSpy = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const blocked: Array<[string, string]> = [
|
||||
['loopback IPv4', 'http://127.0.0.1/mcp'],
|
||||
['private 10/8', 'http://10.0.0.5/mcp'],
|
||||
['private 192.168/16', 'http://192.168.1.1/mcp'],
|
||||
['cloud metadata link-local', 'http://169.254.169.254/latest/meta-data/'],
|
||||
['loopback IPv6 (bracketed)', 'http://[::1]:8080/mcp'],
|
||||
];
|
||||
|
||||
it.each(blocked)(
|
||||
'rejects a request to %s without opening a socket',
|
||||
async (_label, url) => {
|
||||
const { service } = buildService(() => '{}');
|
||||
await expect(guardedFetchOf(service)(url)).rejects.toThrow(
|
||||
/blocked request/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unparseable URL as a blocked request', async () => {
|
||||
const { service } = buildService(() => '{}');
|
||||
await expect(
|
||||
guardedFetchOf(service)('::: not a url :::'),
|
||||
).rejects.toThrow('blocked request: invalid URL');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a public IP literal and forwards through the pinned dispatcher', async () => {
|
||||
const { service } = buildService(() => '{}');
|
||||
const res = await guardedFetchOf(service)('http://8.8.8.8/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
// The init MUST carry the SSRF-pinned undici dispatcher (the rebinding pin);
|
||||
// dropping it would let undici do a second, unchecked DNS resolution.
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit & {
|
||||
dispatcher?: unknown;
|
||||
};
|
||||
expect(init.dispatcher).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||
import * as loader from './docmost-client.loader';
|
||||
import type { DocmostClientLike } from './docmost-client.loader';
|
||||
// The real zod-agnostic registry, imported from source so the contract is checked
|
||||
// against exactly what the @docmost/mcp package ships (no hand-stub).
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||
|
||||
/**
|
||||
* CONTRACT: SHARED_TOOL_SPECS <-> in-app tool wiring parity.
|
||||
*
|
||||
* `packages/mcp/src/tool-specs.ts` is the single source of truth for the tools
|
||||
* that are intentionally IDENTICAL across the standalone MCP server (zod v3) and
|
||||
* the in-app AI-SDK service (zod v4). The in-app service builds each one via
|
||||
* `sharedTool(sharedToolSpecs.<key>, execute)`, keyed by the spec's `inAppKey`.
|
||||
*
|
||||
* This test fails the build if a spec is added to the registry but never wired
|
||||
* in-app, if an `inAppKey` is renamed without updating the service, if the
|
||||
* description drifts between the registry and the exposed tool, if the
|
||||
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
|
||||
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
|
||||
*
|
||||
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
|
||||
* and the ESM loader is mocked so `forUser()` never dynamically imports the
|
||||
* package.
|
||||
*/
|
||||
describe('SHARED_TOOL_SPECS contract parity', () => {
|
||||
// Empty fake client: no tool is executed here — every assertion is on tool
|
||||
// presence / metadata / schema, so the client methods are never called.
|
||||
const fakeClient: Partial<DocmostClientLike> = {};
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
|
||||
let tools: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
// Feed the service the SAME registry this test asserts against.
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<
|
||||
string,
|
||||
loader.SharedToolSpec
|
||||
>,
|
||||
});
|
||||
const service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }) } as never,
|
||||
);
|
||||
tools = (await service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
)) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
afterAll(() => jest.restoreAllMocks());
|
||||
|
||||
// camelCase -> snake_case, matching the registry's mcpName convention.
|
||||
const toSnake = (s: string) =>
|
||||
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
|
||||
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
|
||||
// above otherwise narrows to a union where some members lack buildShape.
|
||||
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array<
|
||||
[string, loader.SharedToolSpec]
|
||||
>;
|
||||
|
||||
// Sanity: the registry is non-empty, so the per-spec table below is not vacuous.
|
||||
it('registry is non-empty', () => {
|
||||
expect(specEntries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe.each(specEntries)('spec "%s"', (registryKey, spec) => {
|
||||
it('registry key equals its inAppKey', () => {
|
||||
// The service indexes the registry by property name; a key != inAppKey
|
||||
// would wire the wrong (or no) tool.
|
||||
expect(spec.inAppKey).toBe(registryKey);
|
||||
});
|
||||
|
||||
it('mcpName is the snake_case form of inAppKey', () => {
|
||||
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
|
||||
});
|
||||
|
||||
it('is exposed in-app under its inAppKey', () => {
|
||||
// Fails if a spec is added to the registry but never wired in forUser().
|
||||
expect(tools[spec.inAppKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposed tool's description matches the registry description", () => {
|
||||
const tool = tools[spec.inAppKey] as { description: string };
|
||||
expect(tool.description).toBe(spec.description);
|
||||
});
|
||||
|
||||
it("exposed tool's input-schema keys match buildShape (incl. required)", () => {
|
||||
const tool = tools[spec.inAppKey] as {
|
||||
inputSchema: { jsonSchema: { properties?: Record<string, unknown>; required?: string[] } };
|
||||
};
|
||||
const json = tool.inputSchema.jsonSchema;
|
||||
const actualKeys = Object.keys(json.properties ?? {}).sort();
|
||||
|
||||
// Derive the spec's declared shape with THIS layer's zod (v4) — the same
|
||||
// call the service makes — then compare key sets and required-ness.
|
||||
const shape = spec.buildShape ? spec.buildShape(z) : {};
|
||||
const expectedKeys = Object.keys(shape).sort();
|
||||
expect(actualKeys).toEqual(expectedKeys);
|
||||
|
||||
// A non-.optional() field must surface as required in the advertised schema.
|
||||
const expectedRequired = Object.entries(shape)
|
||||
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
|
||||
.map(([k]) => k)
|
||||
.sort();
|
||||
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,110 @@
|
||||
import { Readable } from 'stream';
|
||||
import { StorageService } from './storage.service';
|
||||
import type { StorageDriver } from './interfaces';
|
||||
|
||||
// Direct instantiation with a stub driver. The Test.createTestingModule form
|
||||
// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only
|
||||
// needs the service to construct.
|
||||
describe('StorageService', () => {
|
||||
/**
|
||||
* StorageService is a thin facade over the injected StorageDriver: each public
|
||||
* method must forward to the driver with the SAME arguments and return/await the
|
||||
* driver's result unchanged (the read paths return it; the write paths await it).
|
||||
* A mock driver lets us assert that delegation exactly, with no real S3/disk IO.
|
||||
*/
|
||||
describe('StorageService delegation', () => {
|
||||
// Every driver method is a jest mock so we can assert call args + return passing.
|
||||
function buildDriver(): jest.Mocked<StorageDriver> {
|
||||
return {
|
||||
upload: jest.fn().mockResolvedValue(undefined),
|
||||
uploadStream: jest.fn().mockResolvedValue(undefined),
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
read: jest.fn(),
|
||||
readStream: jest.fn(),
|
||||
readRangeStream: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getSignedUrl: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
getDriver: jest.fn(),
|
||||
getDriverName: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
} as unknown as jest.Mocked<StorageDriver>;
|
||||
}
|
||||
|
||||
let driver: jest.Mocked<StorageDriver>;
|
||||
let service: StorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new StorageService(
|
||||
{} as any, // storageDriver
|
||||
);
|
||||
driver = buildDriver();
|
||||
service = new StorageService(driver as unknown as StorageDriver);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('upload forwards path + content to the driver', async () => {
|
||||
const buf = Buffer.from('data');
|
||||
await service.upload('a/b.png', buf);
|
||||
expect(driver.upload).toHaveBeenCalledWith('a/b.png', buf);
|
||||
});
|
||||
|
||||
it('uploadStream forwards path, stream and options', async () => {
|
||||
const stream = Readable.from(['x']);
|
||||
await service.uploadStream('a/b.bin', stream, { recreateClient: true });
|
||||
expect(driver.uploadStream).toHaveBeenCalledWith('a/b.bin', stream, {
|
||||
recreateClient: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('copy forwards both paths', async () => {
|
||||
await service.copy('from.txt', 'to.txt');
|
||||
expect(driver.copy).toHaveBeenCalledWith('from.txt', 'to.txt');
|
||||
});
|
||||
|
||||
it('read returns the driver buffer unchanged', async () => {
|
||||
const buf = Buffer.from('content');
|
||||
driver.read.mockResolvedValue(buf);
|
||||
await expect(service.read('f.txt')).resolves.toBe(buf);
|
||||
expect(driver.read).toHaveBeenCalledWith('f.txt');
|
||||
});
|
||||
|
||||
it('readStream returns the driver stream unchanged', async () => {
|
||||
const stream = Readable.from(['y']);
|
||||
driver.readStream.mockResolvedValue(stream);
|
||||
await expect(service.readStream('f.bin')).resolves.toBe(stream);
|
||||
expect(driver.readStream).toHaveBeenCalledWith('f.bin');
|
||||
});
|
||||
|
||||
it('readRangeStream forwards the range object and returns the stream', async () => {
|
||||
const stream = Readable.from(['z']);
|
||||
driver.readRangeStream.mockResolvedValue(stream);
|
||||
const range = { start: 0, end: 99 };
|
||||
await expect(service.readRangeStream('f.bin', range)).resolves.toBe(stream);
|
||||
expect(driver.readRangeStream).toHaveBeenCalledWith('f.bin', range);
|
||||
});
|
||||
|
||||
it('exists returns the driver boolean', async () => {
|
||||
driver.exists.mockResolvedValue(false);
|
||||
await expect(service.exists('missing')).resolves.toBe(false);
|
||||
expect(driver.exists).toHaveBeenCalledWith('missing');
|
||||
});
|
||||
|
||||
it('getSignedUrl forwards path + expiry and returns the signed url', async () => {
|
||||
driver.getSignedUrl.mockResolvedValue('https://signed/url');
|
||||
await expect(service.getSignedUrl('f.png', 600)).resolves.toBe(
|
||||
'https://signed/url',
|
||||
);
|
||||
expect(driver.getSignedUrl).toHaveBeenCalledWith('f.png', 600);
|
||||
});
|
||||
|
||||
it('getUrl returns the driver url synchronously', () => {
|
||||
driver.getUrl.mockReturnValue('https://cdn/f.png');
|
||||
expect(service.getUrl('f.png')).toBe('https://cdn/f.png');
|
||||
expect(driver.getUrl).toHaveBeenCalledWith('f.png');
|
||||
});
|
||||
|
||||
it('delete forwards the path', async () => {
|
||||
await service.delete('old.txt');
|
||||
expect(driver.delete).toHaveBeenCalledWith('old.txt');
|
||||
});
|
||||
|
||||
it('getDriverName returns the driver name', () => {
|
||||
driver.getDriverName.mockReturnValue('s3');
|
||||
expect(service.getDriverName()).toBe('s3');
|
||||
expect(driver.getDriverName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user