Files
OmniRoute/tests/unit/executor-notion-web-thread-sessions.test.ts
Diego Rodrigues de Sa e Souza 1e0886cd09 test: hermetic notion thread-session mocks + drop duplicated usage-analytics suite (#8392)
* test: hermetic TLS mock for notion thread-session suite + drop duplicated usage-analytics file

Root cause A (#8159): sendNotionInferenceRequest() in
open-sse/executors/notion-web.ts was migrated from fetch() to
tlsFetchNotion() (open-sse/services/notionTlsClient.ts, native
tls-client-node binary) to get past Notion's Cloudflare TLS
fingerprinting. #8159 updated the mock in the sibling
tests/unit/executor-notion-web.test.ts (installNotionTlsMock, wired
through __setTlsFetchOverrideForTesting) but never touched
tests/unit/executor-notion-web-thread-sessions.test.ts (split out
earlier by #7900) — its 3 execute()-driven tests still mocked
globalThis.fetch, which tlsFetchNotion() never calls once the native
TLS client loads successfully. Confirmed live: all 3 tests hit real
https://app.notion.com with a fake cookie and got a real 401
(~8.1-8.5s each here; on a network with blocked/slow egress this
would instead hang up to the client's ~190s timeout+grace per test —
a CI-hang risk).

Fix: replicate installNotionTlsMock verbatim from the sibling file
into executor-notion-web-thread-sessions.test.ts so the 3 tests mock
the TLS override point instead of global fetch. Suite is now fully
hermetic — 8/8 pass, no network I/O, total runtime 31.0s -> 8.0s.

Root cause B (#7700): tests/unit/usage-analytics-route-extra.test.ts
was created as a byte-for-byte duplicate of 10 of the 22 tests in
tests/unit/usage-analytics-route.test.ts. #7300 later fixed a fixture
bug in the retention-window boundary test ("does not double-count raw
and aggregated rows") in the main file only — reading
getUserDatabaseSettings().retention.usageHistory live instead of a
hardcoded 30-day cutoff (default retention is 365 days) — leaving the
duplicate copy on the stale hardcoded value, which now fails (1 !== 2).

Fix: delete the duplicate file. All 10 of its test names exist
verbatim in the main file (verified with comm -12) and that file
passes 22/22:
  - does not double-count raw and aggregated rows
  - does not persist guessed API key attribution
  - does not throw Unknown named parameter on short range (needsAggregated=false)
  - does not throw Unknown named parameter with apiKey filter on long range
  - groups renamed API key usage by stable ID
  - includes activityMap for heatmap
  - includes cost by API key
  - omits global aggregates when filtering by API key
  - returns 500 on database errors
  - returns weeklyPattern for the costs dashboard
No coverage loss — same production code, same assertions, one fewer
redundant file.

Validation:
  - RED executor-notion-web-thread-sessions.test.ts: 5 pass / 3 fail
    (401 !== 200, real network hit), 31.0s
  - RED usage-analytics-route-extra.test.ts: 9 pass / 1 fail
    (1 !== 2), 18.7s
  - GREEN executor-notion-web-thread-sessions.test.ts: 8/8 pass, 8.0s,
    hermetic (no network)
  - GREEN executor-notion-web.test.ts (sibling, untouched): 37/37
    pass, byte-identical diff
  - GREEN usage-analytics-route.test.ts (untouched): 22/22 pass,
    byte-identical diff
  - npx eslint on the changed file: clean
  - npm run typecheck:core: clean (exit 0)

Refs #8159
Refs #7300
Refs #7700

* chore(quality): register usage-analytics-route-extra deletion in test-masking allowlist

check:test-masking hard-flags any deleted test file without a
_deletedWithReplacement entry. The deletion is legitimate (100% duplicate
suite, coverage retained verbatim in tests/unit/usage-analytics-route.test.ts)
-- same registration pattern as the video-dashscope entry.

Refs #7700
Refs #7300
2026-07-24 10:00:07 -03:00

350 lines
12 KiB
TypeScript

// Split out of executor-notion-web.test.ts (file-size gate) — Notion AI Web
// thread session continuity: sticky root binding, prefix-hash lookup/store,
// error-retry stickiness, and the OpenAI multi-turn createThread flip
// (createThread:true on turn 1, createThread:false + same threadId on turn 2+).
import { describe, it } from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../open-sse/executors/notion-web.ts");
const { __setTlsFetchOverrideForTesting } = await import(
"../../open-sse/services/notionTlsClient.ts"
);
const COOKIE_WITH_SPACE = "token_v2=xyz; space_id=space-1; notion_user_id=user-1";
/** Mock the Chrome-JA3 path used by sendNotionInferenceRequest (not global fetch). */
function installNotionTlsMock(
handler: (url: string, opts: { headers?: Record<string, string>; body?: string }) => Promise<{
status: number;
text: string;
}>
): () => void {
__setTlsFetchOverrideForTesting(async (url, options) => {
const r = await handler(url, {
headers: options.headers as Record<string, string> | undefined,
body: options.body,
});
return {
status: r.status,
headers: new Headers(),
text: r.text,
body: null,
};
});
return () => __setTlsFetchOverrideForTesting(null);
}
describe("Notion thread session continuity", () => {
const {
__resetNotionThreadSessionsForTests,
conversationPrefixBeforeLastUser,
hashNotionConversation,
notionThreadSessionLookup,
notionThreadSessionStore,
} = mod;
it("first user turn has no prior assistant history (lookup misses)", () => {
assert.deepEqual(
conversationPrefixBeforeLastUser([{ role: "user", content: "hi" }]),
[]
);
// System-only prefix is fine — still no stored thread for a first user turn
const withSys = [
{ role: "system", content: "sys" },
{ role: "user", content: "hi" },
];
assert.deepEqual(conversationPrefixBeforeLastUser(withSys), [
{ role: "system", content: "sys" },
]);
__resetNotionThreadSessionsForTests();
assert.equal(notionThreadSessionLookup("space-1", withSys), null);
});
it("prefix includes prior turns for multi-turn OpenAI history", () => {
const msgs = [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
{ role: "user", content: "next" },
];
const prefix = conversationPrefixBeforeLastUser(msgs);
assert.equal(prefix.length, 2);
assert.equal(prefix[0].content, "hi");
assert.equal(prefix[1].role, "assistant");
});
it("stores threadId after turn 1 and reuses it on turn 2 (same space)", async () => {
__resetNotionThreadSessionsForTests();
const spaceId = "space-1";
const turn1 = [{ role: "user", content: "first question" }];
assert.equal(notionThreadSessionLookup(spaceId, turn1), null);
const threadId = "11111111-2222-3333-4444-555555555555";
notionThreadSessionStore(spaceId, turn1, "assistant reply one", threadId);
const turn2 = [
{ role: "user", content: "first question" },
{ role: "assistant", content: "assistant reply one" },
{ role: "user", content: "follow up" },
];
assert.equal(notionThreadSessionLookup(spaceId, turn2), threadId);
// Different space must not share the thread
assert.equal(notionThreadSessionLookup("other-space", turn2), null);
});
it("reuses thread when turn-1 user was UREW-rewritten but client replays original text", () => {
__resetNotionThreadSessionsForTests();
const spaceId = "space-urew";
const threadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
// What OmniRoute saw after VibeProxy agentic/UREW rewrite on turn 1
const turn1Rewritten = [
{
role: "user",
content:
"Hi! I'm using my local workflow automation tool…\nMy current task: first question",
},
];
notionThreadSessionStore(spaceId, turn1Rewritten, "assistant reply one", threadId);
// SkillsManager / OpenAI client history keeps the original user wording
const turn2Client = [
{ role: "user", content: "first question" },
{ role: "assistant", content: "assistant reply one" },
{ role: "user", content: "follow up" },
];
assert.equal(notionThreadSessionLookup(spaceId, turn2Client), threadId);
});
it("sticky root survives a failed first request (no second createThread)", async () => {
__resetNotionThreadSessionsForTests();
const {
resolveNotionThreadBinding,
notionThreadMarkCreateAttempted,
NotionWebExecutor,
} = mod as typeof mod & {
resolveNotionThreadBinding: (
spaceKey: string,
messages: { role: string; content: string }[],
clientThreadId?: string
) => { threadId: string; createThread: boolean; rootKey: string | null };
notionThreadMarkCreateAttempted: (rootKey: string | null, threadId: string) => void;
};
const spaceId = "space-fail-sticky";
const turn1 = [{ role: "user", content: "will fail once" }];
const b1 = resolveNotionThreadBinding(spaceId, turn1);
assert.equal(b1.createThread, true);
notionThreadMarkCreateAttempted(b1.rootKey, b1.threadId);
// Simulated error: binding for the same conversation must NOT mint a new thread
const b2 = resolveNotionThreadBinding(spaceId, turn1);
assert.equal(b2.threadId, b1.threadId);
assert.equal(b2.createThread, false);
// Live execute: first upstream error (in-band temporarily-unavailable), second ok
const executor = new NotionWebExecutor();
const captured: Array<{ createThread?: boolean; threadId?: string }> = [];
let n = 0;
const restore = installNotionTlsMock(async (_url, opts) => {
const body = JSON.parse(String(opts.body)) as {
createThread?: boolean;
threadId?: string;
};
captured.push(body);
n++;
if (n === 1) {
return {
status: 200,
text: JSON.stringify({
id: "e1",
type: "error",
message: "Something went wrong. Please try again later.",
subType: "temporarily-unavailable",
isRetryable: false,
}),
};
}
const ndjson = [
JSON.stringify({ type: "patch-start", data: { s: [] } }),
JSON.stringify({
type: "record-map",
recordMap: {
thread_message: {
m1: {
value: {
value: {
step: {
type: "agent-inference",
value: [{ type: "text", content: "recovered" }],
},
},
},
},
},
},
}),
].join("\n");
return { status: 200, text: ndjson };
});
try {
const result = await executor.execute({
model: "fable-5",
body: { messages: turn1 },
stream: false,
credentials: { apiKey: "token_v2=test; space_id=space-fail-sticky" },
signal: null,
} as never);
assert.equal(result.response.status, 200);
// Retry must keep the same threadId and flip createThread off
assert.ok(captured.length >= 2);
assert.equal(captured[0]!.threadId, captured[1]!.threadId);
assert.equal(captured[1]!.createThread, false);
const json = (await result.response.json()) as { choices?: { message?: { content?: string } }[] };
assert.match(String(json.choices?.[0]?.message?.content || ""), /recovered/);
} finally {
restore();
__resetNotionThreadSessionsForTests();
}
});
it("hash is stable for the same conversation prefix", () => {
const a = hashNotionConversation("s", [
{ role: "user", content: "x" },
{ role: "assistant", content: "y" },
]);
const b = hashNotionConversation("s", [
{ role: "user", content: "x" },
{ role: "assistant", content: "y" },
]);
assert.equal(a, b);
assert.notEqual(
a,
hashNotionConversation("s", [
{ role: "user", content: "x" },
{ role: "assistant", content: "z" },
])
);
});
it("execute: first request createThread=true; second multi-turn reuses threadId + createThread=false", async () => {
__resetNotionThreadSessionsForTests();
const executor = new mod.NotionWebExecutor();
const captured: Array<{ createThread?: boolean; threadId?: string }> = [];
const restore = installNotionTlsMock(async (_url, opts) => {
captured.push(JSON.parse(String(opts.body)));
const ndjson = [
JSON.stringify({ type: "patch-start", data: { s: [] } }),
JSON.stringify({
type: "record-map",
recordMap: {
thread_message: {
m1: {
value: {
value: {
step: {
type: "agent-inference",
value: [{ type: "text", content: "ok" }],
},
},
},
},
},
},
}),
].join("\n");
return { status: 200, text: ndjson };
});
try {
const r1 = await executor.execute({
model: "fable-5",
body: { messages: [{ role: "user", content: "hello continuity" }] },
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(r1.response.status, 200);
assert.equal(captured[0].createThread, true);
const t1 = captured[0].threadId;
assert.ok(t1 && t1.length > 10);
const json1 = (await r1.response.json()) as { notion_thread_id?: string; id?: string };
assert.equal(json1.notion_thread_id, t1);
const r2 = await executor.execute({
model: "fable-5",
body: {
messages: [
{ role: "user", content: "hello continuity" },
{ role: "assistant", content: "ok" },
{ role: "user", content: "second turn" },
],
},
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
} as never);
assert.equal(r2.response.status, 200);
assert.equal(captured[1].createThread, false);
assert.equal(captured[1].threadId, t1);
} finally {
restore();
__resetNotionThreadSessionsForTests();
}
});
it("execute: honors X-Notion-Thread-Id via ExecuteInput.clientHeaders (not input.headers)", async () => {
__resetNotionThreadSessionsForTests();
const executor = new mod.NotionWebExecutor();
const pinned = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let capturedCreateThread: boolean | undefined;
let capturedThreadId: string | undefined;
const restore = installNotionTlsMock(async (_url, opts) => {
const body = JSON.parse(String(opts.body)) as {
createThread?: boolean;
threadId?: string;
};
capturedCreateThread = body.createThread;
capturedThreadId = body.threadId;
const ndjson = [
JSON.stringify({ type: "patch-start", data: { s: [] } }),
JSON.stringify({
type: "record-map",
recordMap: {
thread_message: {
m1: {
value: {
value: {
step: {
type: "agent-inference",
value: [{ type: "text", content: "ok" }],
},
},
},
},
},
},
}),
].join("\n");
return { status: 200, text: ndjson };
});
try {
// Real ExecuteInput shape: clientHeaders only (headers is undefined).
const result = await executor.execute({
model: "fable-5",
body: { messages: [{ role: "user", content: "resume thread" }] },
stream: false,
credentials: { apiKey: COOKIE_WITH_SPACE },
signal: null,
clientHeaders: { "X-Notion-Thread-Id": pinned },
} as never);
assert.equal(result.response.status, 200);
assert.equal(capturedThreadId, pinned);
// Client-supplied thread id must force follow-up mode (createThread=false).
assert.equal(capturedCreateThread, false);
} finally {
restore();
__resetNotionThreadSessionsForTests();
}
});
});