Merge pull request 'test: unit tests for the 10 candidates (#139)' (#142) from test/unit-tests-139 into develop

Reviewed-on: #142
This commit was merged in pull request #142.
This commit is contained in:
2026-06-23 04:16:50 +03:00
16 changed files with 1053 additions and 150 deletions

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { encodeWavPcm16 } from "./encode-wav";
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
// produce audio the server cannot decode (silence / garbled transcripts), so we
// assert the canonical 44-byte header layout and the sample quantisation rails.
// Read a DataView back out of a Blob. jsdom's Blob does not implement
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
function readView(blob: Blob): Promise<DataView> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
function readStr(view: DataView, offset: number, length: number): string {
let s = "";
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
return s;
}
describe("encodeWavPcm16", () => {
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(4)));
expect(readStr(view, 0, 4)).toBe("RIFF");
expect(readStr(view, 8, 4)).toBe("WAVE");
expect(readStr(view, 12, 4)).toBe("fmt ");
expect(readStr(view, 36, 4)).toBe("data");
});
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples));
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
expect(view.getUint16(22, true)).toBe(1); // channels = mono
expect(view.getUint16(34, true)).toBe(16); // bits per sample
});
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
const sampleRate = 16000;
const samples = new Float32Array(10);
const view = await readView(encodeWavPcm16(samples, sampleRate));
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
});
it("defaults the sample rate to 16000 at offset 24", async () => {
const view = await readView(encodeWavPcm16(new Float32Array(2)));
expect(view.getUint32(24, true)).toBe(16000);
});
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
expect(view8.getUint32(24, true)).toBe(8000);
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
expect(view48.getUint32(24, true)).toBe(48000);
expect(view48.getUint32(28, true)).toBe(48000 * 2);
});
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
// 0 -> 0, and out-of-range values are clamped to the rails first.
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
const view = await readView(encodeWavPcm16(samples));
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
});
it("produces a mono blob of length 44 + samples.length * 2", () => {
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
});
});

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { canCreatePage } from "./can-create-page.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
// Unit tests for `canCreatePage` (new-note-button.tsx). The home screen has no
// active space, so the "New note" button resolves its target from the user's
// writable spaces. This predicate mirrors the server space-ability mapping
// (ADMIN/WRITER can manage pages, READER is read-only). The /spaces list endpoint
// only returns membership.role (not CASL permissions), so a regression here would
// either hide the button for legitimate writers or offer it to read-only members.
function spaceWithRole(role?: SpaceRole): ISpace {
// Only `membership.role` is consulted by the predicate; the rest is filler.
return {
membership: role ? ({ role } as any) : undefined,
} as ISpace;
}
describe("canCreatePage", () => {
it("is true for ADMIN and WRITER roles", () => {
expect(canCreatePage(spaceWithRole(SpaceRole.ADMIN))).toBe(true);
expect(canCreatePage(spaceWithRole(SpaceRole.WRITER))).toBe(true);
});
it("is false for the READER role", () => {
expect(canCreatePage(spaceWithRole(SpaceRole.READER))).toBe(false);
});
it("is false when membership / role is missing", () => {
expect(canCreatePage(spaceWithRole(undefined))).toBe(false);
expect(canCreatePage({} as ISpace)).toBe(false);
});
it("filters an empty space list down to nothing writable", () => {
const spaces: ISpace[] = [
spaceWithRole(SpaceRole.READER),
spaceWithRole(undefined),
];
expect(spaces.filter(canCreatePage)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,15 @@
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
// The /spaces list endpoint returns membership.role but NOT membership.permissions
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
// for the current user when their role is ADMIN or WRITER.
//
// Extracted from new-note-button.tsx into this pure sibling module so it can be
// unit-tested without importing the component (whose dependency chain pulls in
// main.tsx and renders the whole app at import time).
export function canCreatePage(space: ISpace): boolean {
const role = space.membership?.role;
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
}

View File

@@ -6,18 +6,9 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { SpaceRole } from "@/lib/types.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
// The /spaces list endpoint returns membership.role but NOT membership.permissions
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
// for the current user when their role is ADMIN or WRITER.
function canCreatePage(space: ISpace): boolean {
const role = space.membership?.role;
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
}
import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { Schema } from "@tiptap/pm/model";
import { computeHistoryDiff } from "./history-diff.ts";
// Unit tests for `computeHistoryDiff` (history-diff.ts) — the pure core extracted
// from history-editor.tsx. Given the editor schema plus old/new ProseMirror
// document JSON it produces {decorationSet, added, deleted, total}: inline
// decorations for text edits, whole-node decorations for added block nodes
// (image/table), widget "ghosts" for deleted block nodes (callout), and an empty
// diff for the first version or malformed JSON.
//
// We drive it with a hand-built ProseMirror schema rather than the real
// `mainExtensions` because importing the editor extensions pulls in the whole app
// (main.tsx) at module load. The schema below mirrors the relevant shape: a doc of
// block content, an `image` block atom and a `table` block treated as whole-node
// diffs, and a `callout` block treated as a deletable whole node.
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: {
group: "block",
content: "inline*",
toDOM: () => ["p", 0],
},
callout: {
group: "block",
content: "inline*",
toDOM: () => ["div", { class: "callout" }, 0],
},
image: {
group: "block",
atom: true,
attrs: { src: { default: "" } },
toDOM: (node) => ["img", { src: node.attrs.src }],
},
table: {
group: "block",
content: "paragraph+",
toDOM: () => ["table", ["tbody", 0]],
},
text: { group: "inline" },
},
});
const para = (text: string) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
});
const docOf = (...blocks: any[]) => ({ type: "doc", content: blocks });
describe("computeHistoryDiff", () => {
it("returns an empty diff (counts 0) when there is no previous version", () => {
const diff = computeHistoryDiff(schema, docOf(para("hello")), undefined);
expect(diff.added).toBe(0);
expect(diff.deleted).toBe(0);
expect(diff.total).toBe(0);
expect(diff.decorationSet.find()).toHaveLength(0);
});
it("returns an empty diff when content is missing", () => {
const diff = computeHistoryDiff(schema, undefined, docOf(para("x")));
expect(diff.total).toBe(0);
});
it("emits inline decorations and counts for a text edit", () => {
const prev = docOf(para("hello world"));
const next = docOf(para("hello brave world"));
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
const decos = diff.decorationSet.find();
expect(decos.length).toBeGreaterThan(0);
// An inline text addition is rendered with the inline-added class.
const classes = decos.map((d) => (d.spec as any)?.class ?? (d as any).type?.attrs?.class);
const hasInline = JSON.stringify(decos).includes("history-diff-added") ||
classes.some((c) => c === "history-diff-added");
expect(hasInline).toBe(true);
});
it("treats an added image as a whole-node addition", () => {
const prev = docOf(para("text"));
const next = docOf(para("text"), { type: "image", attrs: { src: "a.png" } });
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
expect(JSON.stringify(diff.decorationSet.find())).toContain(
"history-diff-node-added",
);
});
it("treats an added table as a whole-node addition", () => {
const prev = docOf(para("text"));
const next = docOf(para("text"), {
type: "table",
content: [para("cell")],
});
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.added).toBeGreaterThan(0);
expect(JSON.stringify(diff.decorationSet.find())).toContain(
"history-diff-node-added",
);
});
it("renders a widget ghost for a deleted callout", () => {
const prev = docOf(para("text"), {
type: "callout",
content: [{ type: "text", text: "warning" }],
});
const next = docOf(para("text"));
const diff = computeHistoryDiff(schema, next, prev);
expect(diff.deleted).toBeGreaterThan(0);
// The deleted whole node produces a widget decoration (toDOM callback).
const decos = diff.decorationSet.find();
expect(decos.some((d) => (d as any).type?.toDOM || (d as any).type?.widget)).toBe(
true,
);
});
it("falls back to an empty diff (no throw) on malformed version JSON", () => {
const malformed = { type: "doc", content: [{ type: "nonexistent-node" }] };
expect(() =>
computeHistoryDiff(schema, malformed, docOf(para("x"))),
).not.toThrow();
const diff = computeHistoryDiff(schema, malformed, docOf(para("x")));
expect(diff.total).toBe(0);
expect(diff.decorationSet.find()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,168 @@
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { DOMSerializer, Node, Schema } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { recreateTransform } from "@docmost/editor-ext";
export interface HistoryDiff {
decorationSet: DecorationSet;
added: number;
deleted: number;
total: number;
}
// Block-level nodes that are diffed as a whole ("this image/table/callout was
// added/removed") instead of by inline character ranges.
const SPECIAL_NODE_TYPES = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
// Pure core of the history diff (extracted from history-editor.tsx, behaviour
// preserving): given the editor schema and two ProseMirror document JSONs, return
// the decoration set plus added/deleted/total counts. The widget decorations carry
// lazy DOM-building callbacks (only run by ProseMirror at render time), so this
// function itself does no DOM work and needs no live editor instance.
//
// `previousContent` undefined -> first version, so there is nothing to diff
// (empty decorations, all counts 0). Malformed JSON that throws while building
// nodes falls back to the same empty diff so the caller can still render plain
// content without crashing.
export function computeHistoryDiff(
schema: Schema,
content: any,
previousContent?: any,
): HistoryDiff {
const empty: HistoryDiff = {
decorationSet: DecorationSet.empty,
added: 0,
deleted: 0,
total: 0,
};
if (!content || !previousContent) {
return empty;
}
try {
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
const decorations: Decoration[] = [];
let addedCount = 0;
let deletedCount = 0;
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const special = foundSpecialNode as { node: Node; pos: number };
const nodeEnd = special.pos + special.node.nodeSize;
decorations.push(
Decoration.node(special.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
const deletedNode = foundDeletedNode as { node: Node; pos: number };
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(deletedNode.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
const decorationSet = DecorationSet.create(newContent, decorations);
const total = addedCount + deletedCount;
return { decorationSet, added: addedCount, deleted: deletedCount, total };
} catch (e) {
// Malformed version JSON: fall back to a plain (no-diff) render.
console.error("History diff failed:", e);
return empty;
}
}

View File

@@ -3,11 +3,9 @@ import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { computeHistoryDiff } from "./history-diff.ts";
import { useAtom } from "jotai";
import {
diffCountsAtom,
@@ -36,142 +34,18 @@ export function HistoryEditor({
useEffect(() => {
if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) {
try {
const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
// Pure diff computation lives in history-diff.ts; the component keeps the
// editor side-effects (rendering the new content + wiring decorations).
const { decorationSet, added, deleted, total } = computeHistoryDiff(
editor.schema,
content,
previousContent,
);
const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const nodeEnd =
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
decorations.push(
Decoration.node(foundSpecialNode.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(foundDeletedNode!.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content);
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
setDiffCounts({ added, deleted, total });
editor.setOptions({
editorProps: {

View File

@@ -0,0 +1,78 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate, Matches } from 'class-validator';
import { AddLabelsDto } from './label.dto';
// API-boundary validation for label names. `AddLabelsDto.names` applies the
// matcher /^[a-z0-9_-][a-z0-9_~-]*$/ to every element (each: true): a name must
// start with a lowercase letter, digit, hyphen or underscore (NOT a tilde) and
// then contain only those plus tildes. This guards the label storage key against
// uppercase, whitespace, accents and tilde-leading names.
//
// NOTE: the production DTO also runs `@Transform(normalizeLabelName)` BEFORE the
// matcher (trim + collapse whitespace to '-' + lowercase). `normalizeLabelName`
// itself is already covered (utils.spec.ts), so we deliberately do two things:
// 1) lock the raw @Matches regex in isolation (a mirror DTO with ONLY the same
// matcher) for the exact accept/reject set the regex must enforce; and
// 2) sanity-check the real AddLabelsDto end-to-end for inputs whose normalized
// form still exercises the matcher.
// Mirrors ONLY the production matcher so we test the regex, not the transform.
class NameMatchProbe {
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/)
name: string;
}
async function matcherErrors(name: string) {
const dto = plainToInstance(NameMatchProbe, { name });
return validate(dto as object);
}
function hasError(errors: any[], property: string, constraint?: string) {
const err = errors.find((e) => e.property === property);
if (!err) return false;
if (!constraint) return true;
return Object.keys(err.constraints ?? {}).includes(constraint);
}
describe('label name @Matches regex', () => {
it('accepts valid names', async () => {
for (const name of ['foo', 'a~b', '1-2_3', '-lead']) {
expect(hasError(await matcherErrors(name), 'name', 'matches')).toBe(false);
}
});
it('rejects a tilde-leading name', async () => {
expect(hasError(await matcherErrors('~lead'), 'name', 'matches')).toBe(true);
});
it('rejects whitespace, accents and empty', async () => {
expect(hasError(await matcherErrors('a b'), 'name', 'matches')).toBe(true);
expect(hasError(await matcherErrors('héllo'), 'name', 'matches')).toBe(true);
expect(hasError(await matcherErrors(''), 'name', 'matches')).toBe(true);
});
});
describe('AddLabelsDto.names (matcher applied per element)', () => {
async function validateNames(names: unknown) {
const dto = plainToInstance(AddLabelsDto, { pageId: 'p1', names });
return validate(dto as object);
}
it('accepts a list of valid names', async () => {
const errors = await validateNames(['foo', 'a~b', '1-2_3']);
expect(hasError(errors, 'names', 'matches')).toBe(false);
});
it('rejects a tilde-leading name even after normalization', async () => {
// normalizeLabelName lowercases/collapses whitespace but does not strip a
// leading tilde, so the matcher still fails.
const errors = await validateNames(['~lead']);
expect(hasError(errors, 'names', 'matches')).toBe(true);
});
it('rejects an accented name even after normalization', async () => {
const errors = await validateNames(['héllo']);
expect(hasError(errors, 'names', 'matches')).toBe(true);
});
});

View File

@@ -0,0 +1,77 @@
import {
NotificationType,
DIRECT_NOTIFICATION_TYPES,
UPDATES_NOTIFICATION_TYPES,
getTypesForTab,
} from './notification.constants';
// Contract tests for `getTypesForTab` (notification.constants.ts), which maps a
// notification tab to the set of notification types it should contain.
// - 'direct' -> a 5-type whitelist (mentions / comments / permission grants)
// - 'updates' -> exactly [PAGE_UPDATED]
// - 'all' -> undefined (no type filter)
describe('getTypesForTab', () => {
it("returns exactly the 5 whitelisted types for 'direct'", () => {
expect(getTypesForTab('direct')).toEqual([
NotificationType.COMMENT_USER_MENTION,
NotificationType.COMMENT_CREATED,
NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED,
]);
expect(getTypesForTab('direct')).toHaveLength(5);
expect(getTypesForTab('direct')).toBe(DIRECT_NOTIFICATION_TYPES);
});
it("returns [PAGE_UPDATED] for 'updates'", () => {
expect(getTypesForTab('updates')).toEqual([NotificationType.PAGE_UPDATED]);
expect(getTypesForTab('updates')).toBe(UPDATES_NOTIFICATION_TYPES);
});
it("returns undefined (no filter) for 'all'", () => {
expect(getTypesForTab('all')).toBeUndefined();
});
});
// CONTRACT vs the repository query (notification.repo.ts ~line 57):
// direct -> WHERE type != PAGE_UPDATED
// updates -> WHERE type = PAGE_UPDATED
//
// For 'updates' the whitelist and the SQL agree exactly. For 'direct' they
// DIVERGE: the whitelist is a positive 5-type allow-list, but `type != PAGE_UPDATED`
// returns EVERY non-PAGE_UPDATED type — including verification/approval types that
// are NOT in the whitelist. So the repo would surface notifications the 'direct'
// tab is not supposed to contain. We model the repo predicate and assert it should
// match the whitelist; the 'direct' case genuinely fails today, so it is locked with
// `test.failing` (suite stays green, flips red once repo + whitelist are reconciled).
// What the repo's WHERE clause would actually return, given all known types.
const ALL_TYPES = Object.values(NotificationType);
function repoTypesForTab(tab: 'direct' | 'updates'): string[] {
if (tab === 'direct') {
return ALL_TYPES.filter((t) => t !== NotificationType.PAGE_UPDATED);
}
return ALL_TYPES.filter((t) => t === NotificationType.PAGE_UPDATED);
}
describe('getTypesForTab vs notification.repo query', () => {
it("'updates' whitelist matches the repo's `type = PAGE_UPDATED` filter", () => {
expect(new Set(repoTypesForTab('updates'))).toEqual(
new Set(getTypesForTab('updates')),
);
});
// BUG LOCK: the 'direct' whitelist (5 types) does not match what the repo's
// `type != PAGE_UPDATED` filter returns (all non-PAGE_UPDATED types). This SHOULD
// match; it currently does not. Flips green once the repo filters by the whitelist
// (e.g. `type IN (DIRECT_NOTIFICATION_TYPES)`).
test.failing(
"'direct' whitelist matches the repo's `type != PAGE_UPDATED` filter",
() => {
expect(new Set(repoTypesForTab('direct'))).toEqual(
new Set(getTypesForTab('direct')),
);
},
);
});

View File

@@ -0,0 +1,70 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
// Imported exactly as page.service.ts does, so we test the real key generator
// that feeds `position` at the API boundary.
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from './move-page.dto';
// PARITY BUG (Gitea #139, item 6): MovePageDto.position is bounded with
// @MinLength(5) @MaxLength(12), but the actual positions are fractional-indexing
// keys produced by `generateJitteredKeyBetween` (the same generator page.service
// uses). Those bounds do NOT match the generator's real output range:
// - a freshly generated key (null,null) is short (~5 chars) and currently
// squeaks past MinLength(5);
// - but DENSE between-inserts (repeatedly inserting between two adjacent keys)
// grow the key well past 12 chars, which MaxLength(12) would WRONGLY reject —
// a valid ordering key the server itself generated would be refused on move.
//
// The tests below assert the CORRECT contract: any key the generator can produce
// must satisfy the DTO. The genuinely-failing case is marked `test.failing` so the
// suite stays green while locking the bug; it flips red (alerting us) once the DTO
// bounds are widened to cover the generator's real range.
function constraintErrors(position: unknown) {
const dto = plainToInstance(MovePageDto, {
pageId: 'page-1',
position,
});
return validate(dto as object);
}
function hasError(errors: any[], property: string) {
return errors.some((e) => e.property === property);
}
describe('MovePageDto.position vs generateJitteredKeyBetween parity', () => {
it('accepts a freshly generated first key', async () => {
const key = generateJitteredKeyBetween(null, null);
const errors = await constraintErrors(key);
expect(hasError(errors, 'position')).toBe(false);
});
it('accepts a key appended after an existing key', async () => {
const first = generateJitteredKeyBetween(null, null);
const next = generateJitteredKeyBetween(first, null);
const errors = await constraintErrors(next);
expect(hasError(errors, 'position')).toBe(false);
});
// BUG LOCK: dense between-inserts produce keys longer than 12 chars, which
// MaxLength(12) rejects even though they are valid ordering keys. This SHOULD
// pass; it currently fails. Flips green when the DTO bound is fixed.
test.failing(
'accepts dense between-inserted keys (currently rejected by MaxLength(12))',
async () => {
let lo = generateJitteredKeyBetween(null, null);
let hi = generateJitteredKeyBetween(lo, null);
// Repeatedly insert just above `lo`, shrinking the gap so the key grows.
let longest = lo;
for (let i = 0; i < 40; i++) {
const mid = generateJitteredKeyBetween(lo, hi);
if (mid.length > longest.length) longest = mid;
hi = mid;
}
expect(longest.length).toBeGreaterThan(12); // sanity: we produced a long key
const errors = await constraintErrors(longest);
expect(hasError(errors, 'position')).toBe(false);
},
);
});

View File

@@ -1,4 +1,4 @@
import { SearchService } from './search.service';
import { SearchService, buildTsQuery } from './search.service';
describe('SearchService', () => {
it('should be defined', () => {
@@ -99,3 +99,59 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
expect(isTemplateWhereCall(pageBuilder)).toBeUndefined();
});
});
// Unit tests for `buildTsQuery` (extracted from search.service.ts). It turns a raw
// user query into a prefix tsquery string fed to `to_tsquery('english', ...)`.
//
// REAL BUG (Gitea #139, item 10): the previous inline `tsquery(query.trim() + '*')`
// let to_tsquery operator characters through, so adversarial inputs could produce a
// fragment that to_tsquery rejects -> 500. The extraction sanitizes the input
// (strip everything but letters/numbers/whitespace) so these inputs degrade to a
// safe, neutral query with NO throw, while normal queries keep working.
describe('buildTsQuery', () => {
it('builds a prefix query for a normal single word', () => {
expect(buildTsQuery('hello')).toBe('hello:*');
});
it('joins multiple words with AND and a trailing prefix match', () => {
expect(buildTsQuery('foo bar')).toBe('foo&bar:*');
});
it('preserves accented and non-Latin words', () => {
expect(buildTsQuery('héllo café')).toBe('héllo&café:*');
expect(buildTsQuery('日本語')).toBe('日本語:*');
});
it('neutralizes to_tsquery operator inputs without throwing', () => {
// Each of these previously risked an invalid to_tsquery -> 500. They must now
// produce a safe (here empty) query and never throw.
for (const input of ['&', '!', '*', '<->', '\\']) {
expect(() => buildTsQuery(input)).not.toThrow();
expect(buildTsQuery(input)).toBe('');
}
});
it('handles stopword-only input safely', () => {
// pg-tsquery still tokenizes stopwords; to_tsquery reduces them to nothing.
// The important contract is: no throw, and a deterministic string.
expect(() => buildTsQuery('the a of')).not.toThrow();
expect(buildTsQuery('the a of')).toBe('the&a&of:*');
});
it('returns empty string for empty / whitespace-only / null-ish input', () => {
expect(buildTsQuery('')).toBe('');
expect(buildTsQuery(' ')).toBe('');
expect(buildTsQuery(undefined as unknown as string)).toBe('');
});
it('handles a very long input without throwing', () => {
const long = 'a'.repeat(10000);
expect(() => buildTsQuery(long)).not.toThrow();
expect(buildTsQuery(long)).toBe(`${long}:*`);
});
it('strips punctuation embedded in otherwise valid words', () => {
expect(buildTsQuery('c++ code')).toBe('c&code:*');
expect(buildTsQuery('a-b-c')).toBe('a&b&c:*');
});
});

View File

@@ -12,6 +12,28 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
// Build a safe prefix tsquery string from a raw user query.
//
// The previous inline form `tsquery(query.trim() + '*')` passed user input
// (including to_tsquery operators like `&`, `|`, `!`, `<->`, `*`, backslashes)
// straight through. pg-tsquery would then emit operator fragments that
// `to_tsquery('english', ...)` can reject as a syntax error, turning a search
// into a 500. We strip everything that is not a letter, number or whitespace
// BEFORE handing the text to pg-tsquery, so adversarial input degrades to a
// neutral (possibly empty) query instead of throwing, while normal word queries
// (incl. accented / non-Latin words) are unaffected.
export function buildTsQuery(raw: string): string {
const cleaned = (raw ?? '')
.normalize('NFC')
// Keep Unicode letters/numbers and whitespace; drop everything else.
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return '';
return tsquery(cleaned + '*');
}
@Injectable()
export class SearchService {
constructor(
@@ -34,7 +56,7 @@ export class SearchService {
if (query.length < 1) {
return { items: [] };
}
const searchQuery = tsquery(query.trim() + '*');
const searchQuery = buildTsQuery(query);
let queryResults = this.db
.selectFrom('pages')

View File

@@ -0,0 +1,184 @@
import { describe, it, expect } from "vitest";
import {
getEmbedUrlAndProvider,
getEmbedProviderById,
embedProviders,
} from "./embed-provider";
// Contract tests for the embed providers (embed-provider.ts). `getEmbedUrlAndProvider`
// matches a pasted URL against an ordered list of provider regexes and rewrites it
// to the provider's canonical embeddable URL; if nothing matches it falls back to a
// raw iframe. Each provider has a share-URL -> embed-URL contract plus passthrough
// for already-embedded URLs. A regression here means an embed silently renders the
// wrong thing or an unsupported provider, so we pin all 11 providers.
describe("getEmbedProviderById", () => {
it("looks providers up case-insensitively by id", () => {
expect(getEmbedProviderById("youtube")?.name).toBe("YouTube");
expect(getEmbedProviderById("YOUTUBE")?.name).toBe("YouTube");
expect(getEmbedProviderById("gdrive")?.name).toBe("Google Drive");
});
it("returns undefined for an unknown id", () => {
expect(getEmbedProviderById("notaprovider")).toBeUndefined();
});
it("registers exactly 11 providers", () => {
expect(embedProviders).toHaveLength(11);
});
});
describe("getEmbedUrlAndProvider", () => {
describe("YouTube", () => {
it("rewrites watch?v / youtu.be / m. / music. to youtube-nocookie embeds", () => {
const expected = "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ";
for (const url of [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
]) {
expect(getEmbedUrlAndProvider(url)).toEqual({
provider: "youtube",
embedUrl: expected,
});
}
});
it("passes an already-/embed/ URL through unchanged", () => {
const url = "https://www.youtube.com/embed/dQw4w9WgXcQ";
expect(getEmbedUrlAndProvider(url)).toEqual({
provider: "youtube",
embedUrl: url,
});
});
});
describe("Vimeo", () => {
it("extracts the numeric video id from channel/group/album/plain URLs", () => {
expect(getEmbedUrlAndProvider("https://vimeo.com/123456789").embedUrl).toBe(
"https://player.vimeo.com/video/123456789",
);
expect(
getEmbedUrlAndProvider(
"https://vimeo.com/channels/staffpicks/123456789",
).embedUrl,
).toBe("https://player.vimeo.com/video/123456789");
expect(
getEmbedUrlAndProvider("https://vimeo.com/groups/name/videos/123456789")
.embedUrl,
).toBe("https://player.vimeo.com/video/123456789");
expect(
getEmbedUrlAndProvider("https://vimeo.com/album/123/video/456789")
.embedUrl,
).toBe("https://player.vimeo.com/video/456789");
});
});
describe("Loom", () => {
it("rewrites /share/ to /embed/", () => {
expect(getEmbedUrlAndProvider("https://loom.com/share/abc123")).toEqual({
provider: "loom",
embedUrl: "https://loom.com/embed/abc123",
});
});
it("passes an already-/embed/ URL through", () => {
const url = "https://loom.com/embed/abc123";
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
});
});
describe("Airtable", () => {
it("rewrites a share URL to an /embed/ URL", () => {
expect(
getEmbedUrlAndProvider("https://airtable.com/shrABC123/tblXYZ").embedUrl,
).toBe("https://airtable.com/embed/shrABC123/tblXYZ");
});
it("passes an already-/embed/ URL through", () => {
const url = "https://airtable.com/embed/shrABC123";
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
});
});
describe("Miro", () => {
it("rewrites /app/board/ to a /app/live-embed/ URL", () => {
const res = getEmbedUrlAndProvider("https://miro.com/app/board/uXjVABC=");
expect(res.provider).toBe("miro");
expect(res.embedUrl).toContain("https://miro.com/app/live-embed/uXjVABC=");
});
it("passes an already-/live-embed/ URL through", () => {
const url = "https://miro.com/app/live-embed/uXjVABC=?embedMode=view_only";
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
});
});
describe("Figma", () => {
it("wraps the file URL in the figma embed host (id length 22..128)", () => {
const id22 = "a".repeat(22);
const id128 = "b".repeat(128);
const url22 = `https://www.figma.com/file/${id22}/Design`;
const url128 = `https://www.figma.com/design/${id128}/Design`;
expect(getEmbedUrlAndProvider(url22).embedUrl).toBe(
`https://www.figma.com/embed?url=${url22}&embed_host=docmost`,
);
expect(getEmbedUrlAndProvider(url128).provider).toBe("figma");
});
it("does NOT match a too-short id (< 22 chars) -> iframe fallback", () => {
const url = `https://www.figma.com/file/${"a".repeat(10)}/Design`;
expect(getEmbedUrlAndProvider(url).provider).toBe("iframe");
});
});
describe("Google Drive / Sheets", () => {
it("rewrites a gdrive file URL to /preview using the id (match[4])", () => {
expect(
getEmbedUrlAndProvider("https://drive.google.com/file/d/1AbC_dEf-Gh/view")
.embedUrl,
).toBe("https://drive.google.com/file/d/1AbC_dEf-Gh/preview");
});
it("passes a gsheets URL through unchanged", () => {
const url = "https://docs.google.com/spreadsheets/d/1AbC_dEf-Gh/edit";
expect(getEmbedUrlAndProvider(url)).toEqual({
provider: "google sheets",
embedUrl: url,
});
});
});
describe("Typeform / Framer (passthrough providers)", () => {
it("passes typeform and framer URLs through unchanged", () => {
const tf = "https://my.typeform.com/to/abc123";
expect(getEmbedUrlAndProvider(tf)).toEqual({
provider: "typeform",
embedUrl: tf,
});
const framer = "https://www.framer.com/embed/foo-bar";
expect(getEmbedUrlAndProvider(framer)).toEqual({
provider: "framer",
embedUrl: framer,
});
});
});
describe("fallback", () => {
it("returns the raw iframe provider for an unknown URL", () => {
const url = "https://example.com/some/random/page";
expect(getEmbedUrlAndProvider(url)).toEqual({
provider: "iframe",
embedUrl: url,
});
});
it("returns iframe for junk / non-URL input", () => {
expect(getEmbedUrlAndProvider("not a url at all")).toEqual({
provider: "iframe",
embedUrl: "not a url at all",
});
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { clampIndent } from "./indent";
// Unit tests for `clampIndent` (indent.ts) — the pure core of the indent
// extension. The extension stores an integer `indent` level on paragraphs and
// headings (default range [0, 8]); `clampIndent` keeps every code path
// (increment via Tab, outdent via Shift-Tab, and parsing junk `data-indent`
// attributes from pasted HTML) inside the configured bounds. A regression would
// let an out-of-range / NaN level reach renderHTML and produce broken padding.
//
// NOTE: the "excluded containers stay flat" behaviour (paragraphs inside list
// items / table cells / code blocks) lives in `updateIndent` /
// `appendTransaction`, which require a real ProseMirror EditorState and document
// resolution — it cannot be isolated into a pure function, so it is intentionally
// out of scope here and is exercised at the extension/editor level.
const MIN = 0;
const MAX = 8;
describe("clampIndent", () => {
it("leaves in-range values untouched", () => {
expect(clampIndent(0, MIN, MAX)).toBe(0);
expect(clampIndent(4, MIN, MAX)).toBe(4);
expect(clampIndent(8, MIN, MAX)).toBe(8);
});
it("clamps increments at the max (8)", () => {
// Tab at level 8 would compute 9 -> stays at 8.
expect(clampIndent(8 + 1, MIN, MAX)).toBe(8);
expect(clampIndent(100, MIN, MAX)).toBe(8);
});
it("clamps outdents at the min (0)", () => {
// Shift-Tab at level 0 would compute -1 -> stays at 0.
expect(clampIndent(0 - 1, MIN, MAX)).toBe(0);
expect(clampIndent(-100, MIN, MAX)).toBe(0);
});
it("treats non-finite junk (NaN / Infinity) as the min", () => {
// parseInt('abc', 10) === NaN, which must not propagate to the attribute.
expect(clampIndent(NaN, MIN, MAX)).toBe(MIN);
expect(clampIndent(Infinity, MIN, MAX)).toBe(MIN);
expect(clampIndent(-Infinity, MIN, MAX)).toBe(MIN);
});
it("truncates fractional values toward zero before clamping", () => {
expect(clampIndent(3.9, MIN, MAX)).toBe(3);
expect(clampIndent(-0.5, MIN, MAX)).toBe(MIN);
});
it("clamps junk data-indent values (negative / > max) to the rails", () => {
// Mirrors parseHTML(parseInt(data-indent, 10)) for adversarial pasted HTML.
expect(clampIndent(-3, MIN, MAX)).toBe(MIN);
expect(clampIndent(42, MIN, MAX)).toBe(MAX);
});
});

View File

@@ -33,7 +33,9 @@ const NON_INDENTABLE_ANCESTORS = new Set([
'codeBlock',
]);
const clampIndent = (value: number, min: number, max: number): number => {
// Exported for unit testing: clamps a (possibly junk) indent level into the
// [min, max] range, treating any non-finite value as `min`.
export const clampIndent = (value: number, min: number, max: number): number => {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
};

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { sanitizeUrl, isInternalFileUrl } from "./utils";
// Security contract tests for the editor URL helpers (utils.ts).
// `sanitizeUrl` wraps @braintree/sanitize-url and maps its "about:blank" XSS
// sentinel to "" so callers can treat empty as "blocked". `isInternalFileUrl`
// decides whether a URL points at our own file-serving routes (used to skip
// external-link affordances). A regression here is a stored-XSS or SSRF vector.
describe("sanitizeUrl", () => {
it("blocks dangerous schemes (returns empty string)", () => {
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
// case-insensitive + leading whitespace must not bypass the filter
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
});
it("returns empty string for empty / undefined input", () => {
expect(sanitizeUrl(undefined)).toBe("");
expect(sanitizeUrl("")).toBe("");
});
it("allows safe https, relative file and mailto URLs", () => {
// braintree normalises https URLs (may add a trailing slash); just assert
// the scheme survives and it is not blanked out.
expect(sanitizeUrl("https://example.com/page")).toMatch(/^https:\/\/example\.com\/page/);
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
expect(sanitizeUrl("mailto:user@example.com")).toBe("mailto:user@example.com");
});
});
describe("isInternalFileUrl", () => {
it("is true only for /api/files/ and /files/ prefixes", () => {
expect(isInternalFileUrl("/api/files/abc")).toBe(true);
expect(isInternalFileUrl("/files/abc")).toBe(true);
});
it("trims whitespace before matching the prefix", () => {
expect(isInternalFileUrl(" /api/files/abc")).toBe(true);
expect(isInternalFileUrl("\t/files/abc")).toBe(true);
});
it("is false for external URLs and other paths", () => {
expect(isInternalFileUrl("https://example.com/api/files/abc")).toBe(false);
expect(isInternalFileUrl("/other/files/abc")).toBe(false);
expect(isInternalFileUrl("/apifiles/abc")).toBe(false);
});
it("is false for empty / undefined input", () => {
expect(isInternalFileUrl(undefined)).toBe(false);
expect(isInternalFileUrl("")).toBe(false);
});
});