test: cover features since 053a9c0d + repair test tooling
Add ~330 tests across server (Jest), client (Vitest), editor-ext (Vitest)
and packages/mcp (node:test) for the gitmost features added since
053a9c0d: AI chat, AI agent roles, public-share assistant, MCP per-user
auth, HTML embed, page templates/embed, realtime tree, tree
expand/collapse, and the AI-settings UI.
Test-tooling fixes (prerequisite, were silently hiding coverage):
- Repair 3 page-template specs broken by the 11-arg TransclusionService
constructor; they never compiled, so template access-control / content
-leak / unsync-strip coverage was fictitious.
- Build @docmost/editor-ext before server tests via a `pretest` hook;
the stale dist omitted the new HtmlEmbed/PageEmbed exports (TS2305).
- Let jest resolve the .tsx email templates: add `tsx` to
moduleFileExtensions and widen the ts-jest transform to (t|j)sx?.
Behaviour-preserving "extract pure core" refactors that the tests drive:
- server: resolveShareAssistantRequest + uiMessageTextLength
(public-share controller), decideBasicGate + mapAuthResultToResponse
(mcp), buildErrorAssistantRecord (ai-chat), jsonbObject export (roles).
- client: render-raw-html + shouldExecute/canEdit, decide-embed-state,
page-embed picker utils, tree-socket reducers, open/close branch maps,
isEndpointConfigured/resolveKeyField; buildTreeWithChildren now treats
a permission-trimmed orphan as a root instead of crashing.
Deferred (need a test DB or HTTP harness, documented in the specs):
repo-level Postgres integration tests and the public-share XFF E2E.
Pre-existing DI/lib0-ESM suite failures are untouched and out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCardStatus } from './ai-provider-settings';
|
||||
import {
|
||||
resolveCardStatus,
|
||||
isEndpointConfigured,
|
||||
resolveKeyField,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
it('returns "off" when not configured and not enabled', () => {
|
||||
@@ -18,3 +22,52 @@ describe('resolveCardStatus', () => {
|
||||
expect(resolveCardStatus(true, true)).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEndpointConfigured', () => {
|
||||
it('configured when model and the endpoint own base URL are set', () => {
|
||||
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('configured by inheriting the chat base URL when own base is empty', () => {
|
||||
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('not configured when model is set but both base URLs are empty', () => {
|
||||
expect(isEndpointConfigured('m', '', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when both base URLs are whitespace-only', () => {
|
||||
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when the model is whitespace-only', () => {
|
||||
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeyField (write-only key payload)', () => {
|
||||
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
|
||||
it('typed a value -> set the new key', () => {
|
||||
expect(resolveKeyField('sk-new', false)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('typed a value wins even if cleared was also flagged', () => {
|
||||
expect(resolveKeyField('sk-new', true)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('cleared (empty buffer) -> set the key to empty string', () => {
|
||||
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
|
||||
});
|
||||
|
||||
it('untouched (empty buffer, not cleared) -> omit the key', () => {
|
||||
expect(resolveKeyField('', false)).toEqual({ set: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,33 @@ export function resolveCardStatus(
|
||||
return enabled ? "warning" : "off";
|
||||
}
|
||||
|
||||
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
|
||||
// when its model is set AND it has a usable base URL: either its own base URL is
|
||||
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
|
||||
// All inputs are trimmed so whitespace-only values do not count as filled.
|
||||
export function isEndpointConfigured(
|
||||
model: string,
|
||||
ownBase: string,
|
||||
chatBase: string,
|
||||
): boolean {
|
||||
return (
|
||||
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
|
||||
);
|
||||
}
|
||||
|
||||
// Pure + unit-testable. Write-only API-key payload semantics:
|
||||
// - typed a value (buffer non-empty) -> set it
|
||||
// - explicitly cleared -> send '' to clear the stored key
|
||||
// - untouched (empty buffer, not cleared) -> omit the key entirely
|
||||
export function resolveKeyField(
|
||||
buffer: string,
|
||||
cleared: boolean,
|
||||
): { set: true; value: string } | { set: false } {
|
||||
if (buffer.length > 0) return { set: true, value: buffer };
|
||||
if (cleared) return { set: true, value: "" };
|
||||
return { set: false };
|
||||
}
|
||||
|
||||
// Translate the dot's tooltip label. Kept in one place so all three endpoint
|
||||
// cards share identical wording.
|
||||
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
|
||||
@@ -263,29 +290,23 @@ export default function AiProviderSettings() {
|
||||
sttApiStyle: values.sttApiStyle,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back):
|
||||
// Key semantics (never send the stored key back) — see resolveKeyField:
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit the key entirely (leave unchanged)
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
|
||||
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
|
||||
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
const embeddingKeyField = resolveKeyField(
|
||||
values.embeddingApiKey,
|
||||
embeddingKeyCleared,
|
||||
);
|
||||
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
|
||||
|
||||
// Same write-only semantics for the STT-specific key.
|
||||
if (values.sttApiKey.length > 0) {
|
||||
payload.sttApiKey = values.sttApiKey;
|
||||
} else if (sttKeyCleared) {
|
||||
payload.sttApiKey = "";
|
||||
}
|
||||
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
|
||||
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -460,12 +481,12 @@ export default function AiProviderSettings() {
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
const embedConfigured =
|
||||
v.embeddingModel.trim() !== "" &&
|
||||
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const sttConfigured =
|
||||
v.sttModel.trim() !== "" &&
|
||||
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const embedConfigured = isEndpointConfigured(
|
||||
v.embeddingModel,
|
||||
v.embeddingBaseUrl,
|
||||
v.baseUrl,
|
||||
);
|
||||
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
|
||||
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
|
||||
Reference in New Issue
Block a user