Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eae7640f30 | |||
| 0099ba272d | |||
| 826bb491ca | |||
| 7be49c1280 | |||
| c7073b62d1 | |||
| 11b2c55485 |
+21
-1
@@ -129,6 +129,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **A drifted comment suggestion can be re-synced instead of failing forever
|
||||
with a 409.** A suggestion whose stored anchor no longer matched the live
|
||||
document used to reject every apply attempt with an unrecoverable conflict; a
|
||||
new resync path re-reads the live anchor so the suggestion applies against the
|
||||
current text, and orphaned anchors (whose marked run was deleted) are
|
||||
reconciled rather than left blocking. (#496)
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||
images as a row that wraps onto the next line on narrow screens. The row is
|
||||
@@ -352,7 +359,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||
10 min; see `.env.example`). (#489)
|
||||
|
||||
- **Decisions on comment suggestions now leave a durable audit record.**
|
||||
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
||||
subject comment, so the only surviving trace of who decided what is the audit
|
||||
event — but the audit trail was wired to a Noop service that silently
|
||||
swallowed every event. The trail is now DB-backed, so
|
||||
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
||||
comment-decision events) persist to the `audit` table and can be reviewed
|
||||
after the comment is gone. A persistence failure is still swallowed with a
|
||||
warning so it never breaks the originating request. (#496)
|
||||
- **Applying a comment suggestion no longer strips the replaced run's inline
|
||||
formatting.** The suggested text was re-inserted carrying only the comment
|
||||
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||
prevailing formatting of the replaced run is now carried onto the applied
|
||||
text. (#496)
|
||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||
|
||||
@@ -665,13 +665,6 @@ export function updateCacheOnMovePage(
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
|
||||
// guard removes the moved node from the local tree when its new parent is an
|
||||
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
|
||||
// falls back to the server `["breadcrumbs", pageId]` query — which this move
|
||||
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
|
||||
// refocus/navigation.
|
||||
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
|
||||
vi.mock("@/main.tsx", async () => {
|
||||
const { QueryClient } = await import("@tanstack/react-query");
|
||||
return { queryClient: new QueryClient() };
|
||||
});
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { updateCacheOnMovePage } from "./page-query";
|
||||
|
||||
// #523: the tree-side child-loss guard removes the moved node from the local
|
||||
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
|
||||
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
|
||||
// query. That query MUST be invalidated by a move, or the crumbs keep showing
|
||||
// the OLD parent until a refocus/navigation.
|
||||
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
|
||||
id: "moved-page",
|
||||
} as Partial<IPage>);
|
||||
|
||||
const invalidatedBreadcrumbs = spy.mock.calls.some(
|
||||
([arg]) =>
|
||||
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
|
||||
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
|
||||
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
|
||||
);
|
||||
expect(invalidatedBreadcrumbs).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,14 +55,7 @@ type Props<T extends object> = {
|
||||
};
|
||||
|
||||
const DRAG_TYPE = 'doc-tree-item';
|
||||
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
|
||||
// so merely dragging the cursor THROUGH the tree never expands rows — only a
|
||||
// deliberate hold does (#523).
|
||||
const AUTO_EXPAND_MS = 2000;
|
||||
// How long the "a page just moved in here" cue stays on a collapsed target after
|
||||
// a make-child drop. Long enough to notice at a glance during frequent
|
||||
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
|
||||
const DROP_LANDED_HIGHLIGHT_MS = 1800;
|
||||
const AUTO_EXPAND_MS = 500;
|
||||
|
||||
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
const {
|
||||
@@ -100,11 +93,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
const rowRef = useRef<HTMLElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
||||
// Transient "just received a child" cue: a make-child drop no longer expands
|
||||
// the (collapsed) target, so flash the row instead so the move isn't invisible.
|
||||
const [landedChild, setLandedChild] = useState(false);
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelAutoExpand = useCallback(() => {
|
||||
if (autoExpandTimerRef.current) {
|
||||
@@ -260,24 +249,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
? getDragLabel(sourceNode)
|
||||
: 'item';
|
||||
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
||||
// Do NOT auto-expand the target on drop: a drop must leave the node
|
||||
// collapsed. Intentional expansion is handled solely by the
|
||||
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
|
||||
// given by the post-move flash + landed-cue highlight + live-region
|
||||
// announce above. When the make-child target is collapsed, flash a
|
||||
// distinct "child moved in here" cue on the row (it stays collapsed).
|
||||
if (op.kind === 'make-child' && !isOpen) {
|
||||
if (landedChildTimerRef.current) {
|
||||
clearTimeout(landedChildTimerRef.current);
|
||||
}
|
||||
setLandedChild(true);
|
||||
landedChildTimerRef.current = setTimeout(() => {
|
||||
setLandedChild(false);
|
||||
landedChildTimerRef.current = null;
|
||||
}, DROP_LANDED_HIGHLIGHT_MS);
|
||||
}
|
||||
// Restore the openness of the MOVED page itself (source) — untouched
|
||||
// by the above; the target is never expanded here.
|
||||
// After a make-child drop, expand this row so the user sees the
|
||||
// just-dropped child — especially important when the row had no
|
||||
// children before (chevron just appeared) so the drop would
|
||||
// otherwise be invisible.
|
||||
if (op.kind === 'make-child') onToggle(node.id, true);
|
||||
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
||||
},
|
||||
}),
|
||||
@@ -305,17 +281,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
|
||||
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
||||
|
||||
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (landedChildTimerRef.current) {
|
||||
clearTimeout(landedChildTimerRef.current);
|
||||
landedChildTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const effectiveInst =
|
||||
instruction?.type === 'instruction-blocked'
|
||||
? instruction.desired
|
||||
@@ -352,7 +317,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
className={styles.node}
|
||||
data-dragging={isDragging || undefined}
|
||||
data-selected={isSelected || undefined}
|
||||
data-landed-child={landedChild || undefined}
|
||||
data-receiving-drop={
|
||||
receivingDrop === 'make-child'
|
||||
? blocked
|
||||
|
||||
@@ -281,10 +281,10 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||
if (isOpen) {
|
||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||
// Same "unloaded branch" predicate the insert paths use (`isUnloadedBranch`)
|
||||
// so the lazy-load gate and the realtime/DnD inserts can never disagree
|
||||
// about what counts as unloaded (#525).
|
||||
if (treeModel.isUnloadedBranch(node)) {
|
||||
if (
|
||||
node?.hasChildren &&
|
||||
(!node.children || node.children.length === 0)
|
||||
) {
|
||||
const fetched = await fetchAllAncestorChildren({
|
||||
pageId: id,
|
||||
spaceId: node.spaceId,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
|
||||
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
|
||||
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
|
||||
// child-loss guard is exercised end-to-end.
|
||||
const moveMutate = vi.fn().mockResolvedValue({});
|
||||
const updateCacheOnMovePageMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
|
||||
updateCacheOnMovePage: (...args: unknown[]) =>
|
||||
updateCacheOnMovePageMock(...args),
|
||||
}));
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Import AFTER mocks so the hook binds to them.
|
||||
import { useTreeMutation } from "./use-tree-mutation";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
over: Partial<SpaceTreeNode> = {},
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(before: SpaceTreeNode[]) {
|
||||
const store = createStore();
|
||||
store.set(treeDataAtom, before);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
|
||||
return { store, result };
|
||||
}
|
||||
|
||||
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
moveMutate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
|
||||
// F has children on the server but none are loaded here (canonical unloaded
|
||||
// form: hasChildren + children:[]). X sits at root.
|
||||
const before = [
|
||||
node("F", { position: "a0", hasChildren: true, children: [] }),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "F",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const f = treeModel.find(tree, "F");
|
||||
// The guard leaves F unloaded (children stay []), so a later expand fetches
|
||||
// the FULL server set (incl. X) instead of showing a misleading partial [X].
|
||||
// MUTATION: dropping the guard (using the `move` result) would put children
|
||||
// === [X] here and redden this.
|
||||
expect(f?.children).toEqual([]);
|
||||
expect(f?.hasChildren).toBe(true);
|
||||
// X is removed from its old (root) slot; it reappears on expand/load of F.
|
||||
expect(treeModel.find(tree, "X")).toBeNull();
|
||||
// The server move is still persisted.
|
||||
expect(moveMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
|
||||
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
|
||||
// append X — this path does NOT hit the guard.
|
||||
const before = [
|
||||
node("F", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("c1", { position: "a1", parentPageId: "F" })],
|
||||
}),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "F",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const f = treeModel.find(tree, "F");
|
||||
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
|
||||
expect(f?.hasChildren).toBe(true);
|
||||
// X now lives under F.
|
||||
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
|
||||
});
|
||||
|
||||
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
|
||||
// Leaf L has no server children (hasChildren:false). Dropping X in should
|
||||
// show X immediately — the guard must NOT fire here.
|
||||
const before = [
|
||||
node("L", { position: "a0", hasChildren: false, children: [] }),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "L",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const l = treeModel.find(tree, "L");
|
||||
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
|
||||
expect(l?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -61,48 +61,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
if (!source) return;
|
||||
const oldParentId = source.parentPageId ?? null;
|
||||
|
||||
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
|
||||
// We no longer auto-expand the make-child target on drop, so the old
|
||||
// `onToggle(target, true)` — which was ALSO the only trigger of the
|
||||
// corrective lazy-load — is gone. `treeModel.move` materialized
|
||||
// `target.children = [source]` (only the moved node); if the target is an
|
||||
// UNLOADED branch (server has children but none are loaded here), keeping
|
||||
// that partial `[source]` list would defeat the lazy-load gate and hide the
|
||||
// target's OTHER server children (the #159 #1 data-loss class). So for an
|
||||
// unloaded make-child target, build the optimistic tree WITHOUT
|
||||
// materializing source under it: just remove source from its old parent and
|
||||
// flag the target `hasChildren`. The gate stays armed and a later manual
|
||||
// expand fetches the FULL set (incl. the moved page, which the awaited
|
||||
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
|
||||
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
|
||||
const target =
|
||||
op.kind === "make-child"
|
||||
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
|
||||
: null;
|
||||
const unloadedMakeChild =
|
||||
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
|
||||
|
||||
let optimistic: SpaceTreeNode[];
|
||||
if (unloadedMakeChild) {
|
||||
// Do NOT materialize [source] into the unloaded target.
|
||||
optimistic = treeModel.remove(before, sourceId);
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
} else {
|
||||
// optimistic apply with the new position from the payload
|
||||
optimistic = treeModel.update(after, sourceId, {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentPageId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
// For make-child onto a previously-childless (loaded) target: flip
|
||||
// hasChildren on so the new parent shows its chevron.
|
||||
if (op.kind === "make-child") {
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
// optimistic apply with the new position from the payload
|
||||
let optimistic = treeModel.update(after, sourceId, {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentPageId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
|
||||
// If the old parent has no children left, mark hasChildren: false so the
|
||||
// chevron disappears. Without this, the empty parent keeps rendering an
|
||||
@@ -116,6 +79,14 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
}
|
||||
}
|
||||
|
||||
// For make-child onto a previously-childless target: flip hasChildren on
|
||||
// so the new parent shows its chevron.
|
||||
if (op.kind === "make-child") {
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
setData(optimistic);
|
||||
|
||||
try {
|
||||
|
||||
@@ -74,48 +74,6 @@ describe("treeModel.isDescendant", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
||||
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
||||
// present locally (canonical form `children: []`, also `undefined`). A parent
|
||||
// without hasChildren is genuinely empty, not unloaded.
|
||||
describe("treeModel.isUnloadedBranch", () => {
|
||||
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
||||
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("true for hasChildren + undefined children", () => {
|
||||
const n: PH = { id: "p", name: "P", hasChildren: true };
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||
});
|
||||
it("false for hasChildren + already-loaded children", () => {
|
||||
const n: PH = {
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: true,
|
||||
children: [{ id: "c", name: "C" }],
|
||||
};
|
||||
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
||||
});
|
||||
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({
|
||||
id: "p",
|
||||
name: "P",
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
} as PH),
|
||||
).toBe(false);
|
||||
expect(
|
||||
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
||||
).toBe(false);
|
||||
});
|
||||
it("false for null/undefined", () => {
|
||||
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
||||
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeModel.visible", () => {
|
||||
it("returns only root nodes when no openIds", () => {
|
||||
const v = treeModel.visible(fixture, new Set());
|
||||
@@ -239,64 +197,43 @@ describe("treeModel.insertByPosition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
|
||||
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
||||
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
||||
// hide the parent's other real children. The canonical unloaded form here is
|
||||
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
||||
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
||||
// The node is left to be lazy-loaded; the chevron stays enabled.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
||||
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
||||
// here and reredden this expectation.
|
||||
expect(parent?.children).toEqual([]);
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
// ...and the chevron stays enabled so the user can expand to load it.
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
expect(parent?.children).toBeUndefined();
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
// No server children (`hasChildren: false`), so materializing the first child
|
||||
// is correct — nothing is hidden.
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
||||
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
||||
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
||||
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
||||
// parent's other real children. The node is left to be lazy-loaded; only
|
||||
// `hasChildren` is flagged so the chevron appears.
|
||||
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
const parent = treeModel.find(t, "p");
|
||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||
// full set, including this node, on expand).
|
||||
expect(parent?.children).toBeUndefined();
|
||||
expect(treeModel.find(t, "x")).toBeNull();
|
||||
// ...but the chevron is enabled so the user can expand to load it.
|
||||
expect((parent as PH).hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
||||
type PH = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
hasChildren?: boolean;
|
||||
}>;
|
||||
const tree: PH[] = [
|
||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||
];
|
||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||
const t = treeModel.insertByPosition(tree, "p", node);
|
||||
// A loaded (empty) child list is complete, so the node IS inserted.
|
||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,24 +43,6 @@ export const treeModel = {
|
||||
};
|
||||
},
|
||||
|
||||
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
||||
// but none are present locally. The canonical unloaded form in this codebase
|
||||
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
||||
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`),
|
||||
// the realtime insert path (`insertByPosition`) and the DnD move guard, so they
|
||||
// can never drift apart again. A parent WITHOUT `hasChildren` is genuinely empty
|
||||
// (no server children) — inserting its first child is correct, not deferred.
|
||||
isUnloadedBranch<T extends object>(
|
||||
node: TreeNode<T> | null | undefined,
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
||||
return hasChildren && (node.children == null || node.children.length === 0);
|
||||
},
|
||||
|
||||
isDescendant<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
ancestorId: string,
|
||||
@@ -145,15 +127,14 @@ export const treeModel = {
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
||||
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
||||
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||
// defeats the lazy-load gate — which fetches only when children are
|
||||
// absent/empty — so the parent's OTHER real children would never load and the
|
||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||
// chevron appears; expanding fetches the FULL set (including this node).
|
||||
if (parent && treeModel.isUnloadedBranch(parent)) {
|
||||
if (parent && parent.children === undefined) {
|
||||
return treeModel.update(
|
||||
tree,
|
||||
parentId,
|
||||
|
||||
@@ -150,45 +150,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
|
||||
the collapsed target, so the moved page is momentarily invisible; pulse the
|
||||
target row in a distinct teal (NOT the blue make-child highlight, NOT the
|
||||
neutral post-move flash) so the landing is noticeable while the node stays
|
||||
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
|
||||
which the row clears the attribute and the animation stops. */
|
||||
@keyframes landedChildPulse {
|
||||
0% {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-teal-2),
|
||||
rgba(45, 212, 191, 0.30)
|
||||
);
|
||||
outline-color: light-dark(
|
||||
var(--mantine-color-teal-6),
|
||||
var(--mantine-color-teal-5)
|
||||
);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
outline-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.node[data-landed-child="true"] {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -1px;
|
||||
animation: landedChildPulse 0.9s ease-out 2;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.node[data-landed-child="true"] {
|
||||
animation: none;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-teal-1),
|
||||
rgba(45, 212, 191, 0.18)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.dropLine {
|
||||
position: absolute;
|
||||
left: var(--drop-line-indent, 0);
|
||||
|
||||
@@ -82,19 +82,17 @@ describe("applyMoveTreeNode", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
||||
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
||||
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
||||
// The pre-#525 predicate (`children === undefined`) missed this form and
|
||||
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
||||
// HIDING the parent's other real children. Now the move leaves children
|
||||
// unloaded (so expanding fetches the FULL set, including src).
|
||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
||||
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
||||
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
||||
// other real children. Now the move leaves children unloaded (so expanding
|
||||
// fetches the FULL set, including src) and just flags hasChildren.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dstCollapsed", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [],
|
||||
hasChildren: false,
|
||||
children: undefined as unknown as SpaceTreeNode[],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
@@ -107,10 +105,9 @@ describe("applyMoveTreeNode", () => {
|
||||
pageData: {},
|
||||
});
|
||||
const dst = treeModel.find(next, "dstCollapsed");
|
||||
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
||||
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
||||
// `=== undefined` predicate would insert [src] here and redden this.
|
||||
expect(dst?.children).toEqual([]);
|
||||
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
||||
// src) on expand, rather than showing a misleading partial [src] list.
|
||||
expect(dst?.children).toBeUndefined();
|
||||
expect(dst?.hasChildren).toBe(true);
|
||||
// src moved away from its old root slot (it lives under dstCollapsed
|
||||
// server-side and reappears when the parent is expanded/loaded).
|
||||
|
||||
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { AuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
NoopAuditModule,
|
||||
AuditModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
|
||||
@@ -529,4 +529,107 @@ describe('replaceYjsMarkedText', () => {
|
||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
// #496: apply must NOT silently strip the replaced run's inline formatting.
|
||||
// Build a paragraph and format the marked range with extra marks, then assert
|
||||
// the replacement carries them.
|
||||
function buildFormatted(
|
||||
runs: Array<{ text: string; attrs?: Record<string, any> }>,
|
||||
): { 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]);
|
||||
text.insert(0, runs.map((r) => r.text).join(''));
|
||||
let offset = 0;
|
||||
for (const run of runs) {
|
||||
if (run.attrs) text.format(offset, run.text.length, run.attrs);
|
||||
offset += run.text.length;
|
||||
}
|
||||
return { fragment, text };
|
||||
}
|
||||
|
||||
it('preserves the original run formatting (bold + link) on the replacement', () => {
|
||||
const { fragment, text } = buildFormatted([
|
||||
{ text: 'see ' },
|
||||
{
|
||||
text: 'old',
|
||||
attrs: {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
bold: true,
|
||||
link: { href: 'https://x.test' },
|
||||
},
|
||||
},
|
||||
{ text: ' end' },
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'new' });
|
||||
// The comment anchor AND the bold/link marks survive the delete+insert.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'see ' },
|
||||
{
|
||||
insert: 'new',
|
||||
attributes: {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
bold: true,
|
||||
link: { href: 'https://x.test' },
|
||||
},
|
||||
},
|
||||
{ insert: ' end' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
|
||||
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
|
||||
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
|
||||
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
|
||||
// bold on the result only holds if the code genuinely selects the LONGEST run.
|
||||
const { fragment, text } = buildFormatted([
|
||||
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
|
||||
{
|
||||
text: 'bolded',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Z',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
|
||||
// Two equal-length runs (2 chars each) with different formatting, same
|
||||
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
|
||||
// (italic) prevails over the later bold one.
|
||||
const { fragment, text } = buildFormatted([
|
||||
{
|
||||
text: 'AA',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||
},
|
||||
{
|
||||
text: 'BB',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Z',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,10 @@ type MarkedSegment = {
|
||||
length: number;
|
||||
text: string;
|
||||
markAttrs: Record<string, any>;
|
||||
// The FULL attribute set of this delta run — the `comment` mark plus any
|
||||
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
|
||||
// original run's formatting onto the replacement instead of dropping it.
|
||||
attributes: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -202,6 +206,7 @@ export function replaceYjsMarkedText(
|
||||
length,
|
||||
text: insert,
|
||||
markAttrs: markAttr,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
offset += length;
|
||||
@@ -251,15 +256,25 @@ export function replaceYjsMarkedText(
|
||||
return { applied: false, currentText: joinedText };
|
||||
}
|
||||
|
||||
// 3. All guards passed: delete the marked run and re-insert newText with the
|
||||
// same comment attributes at the same offset. Atomic within the caller's
|
||||
// transaction.
|
||||
// 3. All guards passed: delete the marked run and re-insert newText at the
|
||||
// same offset. Atomic within the caller's transaction.
|
||||
const start = segments[0].offset;
|
||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||
const markAttrs = segments[0].markAttrs;
|
||||
|
||||
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
|
||||
// with only the `comment` mark silently dropped bold/italic/code/link of the
|
||||
// replaced text. Yjs applies one flat attribute set to the whole insert, so
|
||||
// when the marked run mixes formatting we pick the DOMINANT segment (the one
|
||||
// covering the most characters) and apply its attributes — a v1 that preserves
|
||||
// the common single-format case exactly and, for a mixed run, keeps the
|
||||
// prevailing style rather than losing all of it. `attributes` already carries
|
||||
// the `comment` mark (every collected segment is filtered on it above), so the
|
||||
// anchor is preserved by copying the run's attribute set verbatim.
|
||||
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
|
||||
const insertAttrs = { ...dominant.attributes };
|
||||
|
||||
node.delete(start, len);
|
||||
node.insert(start, newText, { comment: markAttrs });
|
||||
node.insert(start, newText, insertAttrs);
|
||||
|
||||
return { applied: true, currentText: newText };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||
import { ResyncSuggestionAnchorDto } from './dto/resync-suggestion-anchor.dto';
|
||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
@@ -235,6 +236,39 @@ export class CommentController {
|
||||
return this.commentService.applySuggestion(comment, user, provenance);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('resync-suggestion-anchor')
|
||||
async resyncSuggestionAnchor(
|
||||
@Body() dto: ResyncSuggestionAnchorDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Authorize BEFORE revealing structural detail (mirrors apply/dismiss).
|
||||
// Re-anchoring does NOT change the page text — it only corrects the stored
|
||||
// selection metadata — so the page-level gate is comment access. The service
|
||||
// further restricts it to the suggestion's own author.
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
return this.commentService.resyncSuggestionAnchor(
|
||||
comment,
|
||||
dto.selection,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('dismiss-suggestion')
|
||||
async dismissSuggestion(
|
||||
|
||||
@@ -146,11 +146,19 @@ describe('CommentService — applySuggestion', () => {
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
// #496: hard-deleted row → the audit payload is the only surviving record.
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
metadata: expect.objectContaining({
|
||||
pageId: 'page-1',
|
||||
suggestedText: 'new text',
|
||||
selection: 'old text',
|
||||
commentAuthor: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
@@ -189,17 +197,25 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
|
||||
// NOT deleted; broadcast an update, not a deletion.
|
||||
// NOT deleted.
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// #496 dedup: resolveComment broadcasts `commentResolved` with the enriched
|
||||
// row; finalize must NOT ALSO emit a redundant `commentUpdated`. So the
|
||||
// thread receives exactly ONE resolve broadcast and no update broadcast.
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
expect.objectContaining({ operation: 'commentResolved', comment: UPDATED }),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated' }),
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
@@ -211,6 +227,36 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('re-entry: already applied+resolved WITH replies → emits commentUpdated (dedup does not over-suppress)', async () => {
|
||||
// suggestionAppliedAt set → idempotent finalize; resolvedAt set → resolveComment
|
||||
// is skipped, so there is NO commentResolved broadcast. The applied-stamp state
|
||||
// must still reach clients via a single commentUpdated.
|
||||
const { service, wsService } = makeService(
|
||||
{ applied: false, currentText: 'new text' },
|
||||
true,
|
||||
);
|
||||
|
||||
await service.applySuggestion(
|
||||
suggestionComment({
|
||||
suggestionAppliedAt: new Date(),
|
||||
resolvedAt: new Date(),
|
||||
}),
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
// Nothing resolved this time (already resolved) → no resolve broadcast.
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentResolved' }),
|
||||
);
|
||||
});
|
||||
|
||||
// --- error / rejection branches -----------------------------------------
|
||||
|
||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||
|
||||
@@ -107,11 +107,21 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
// #496: the row is hard-deleted, so the audit payload must carry the
|
||||
// decision's substance (what was suggested, the anchored text, who authored
|
||||
// it, who decided) — it is the only surviving record.
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
metadata: expect.objectContaining({
|
||||
pageId: 'page-1',
|
||||
suggestedText: 'new text',
|
||||
selection: 'old text',
|
||||
commentAuthor: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
|
||||
/**
|
||||
* Coverage for CommentService.resyncSuggestionAnchor (#496): re-anchoring a
|
||||
* suggestion's stored selection (== apply-time expectedText) to the live-doc
|
||||
* substring. The service is built directly with jest-mocked deps (the
|
||||
* @InjectQueue tokens can't be resolved by Test.createTestingModule — see the
|
||||
* sibling specs).
|
||||
*/
|
||||
describe('CommentService — resyncSuggestionAnchor', () => {
|
||||
const UPDATED = { id: 'c-1', selection: 'new anchor', __updated: true } as any;
|
||||
|
||||
function makeService() {
|
||||
const commentRepo: any = {
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
findById: jest.fn(async () => UPDATED),
|
||||
};
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
{} as any,
|
||||
{ emitCommentEvent: jest.fn() } as any,
|
||||
{} as any,
|
||||
{ add: jest.fn() } as any,
|
||||
{ add: jest.fn() } as any,
|
||||
{ log: jest.fn() } as any,
|
||||
);
|
||||
return { service, commentRepo };
|
||||
}
|
||||
|
||||
const suggestion = (over?: Partial<any>): any => ({
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
parentCommentId: null,
|
||||
selection: 'old anchor',
|
||||
suggestedText: 'new text',
|
||||
suggestionAppliedAt: null,
|
||||
resolvedAt: null,
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
it('persists the new selection and returns the enriched comment', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
const out = await service.resyncSuggestionAnchor(
|
||||
suggestion(),
|
||||
'new anchor',
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(commentRepo.updateComment).toHaveBeenCalledWith(
|
||||
{ selection: 'new anchor' },
|
||||
'c-1',
|
||||
);
|
||||
expect(out).toBe(UPDATED);
|
||||
});
|
||||
|
||||
it('is idempotent: no write when the anchor already matches', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
const out = await service.resyncSuggestionAnchor(
|
||||
suggestion({ selection: 'same' }),
|
||||
'same',
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
expect(out).toEqual(suggestion({ selection: 'same' }));
|
||||
});
|
||||
|
||||
it('rejects a non-author (only the suggestion owner may re-anchor)', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(suggestion(), 'new anchor', user({ id: 'other' })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a reply / a comment with no suggestion', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ parentCommentId: 'p-1' }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestedText: null }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects re-anchoring an already applied or resolved suggestion', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestionAppliedAt: new Date() }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ resolvedAt: new Date() }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a no-op selection equal to the suggested text', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestedText: 'new text' }),
|
||||
'new text',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -370,6 +370,76 @@ export class CommentService {
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sync a suggestion's stored `selection` (== apply-time expectedText) to the
|
||||
* RAW substring the inline mark actually covers in the LIVE document (#496).
|
||||
*
|
||||
* The MCP client creates the comment from a DEBOUNCED REST snapshot, then
|
||||
* anchors the mark in the live collab doc. When the two disagree (the doc moved
|
||||
* on in the debounce window) the stored selection no longer equals the marked
|
||||
* text, so EVERY apply 409s ("the commented text changed"). After anchoring the
|
||||
* client re-reads the exact marked substring and calls this to store it, making
|
||||
* apply's strict equality hold.
|
||||
*
|
||||
* Only meaningful for an un-settled top-level suggestion authored by the
|
||||
* caller: applying/resolving freezes the anchor, and a reply-carrying thread is
|
||||
* preserved rather than mutated. The new text must still differ from the
|
||||
* suggestion (else "apply" would be a no-op), preserving create()'s invariant.
|
||||
*/
|
||||
async resyncSuggestionAnchor(
|
||||
comment: Comment,
|
||||
selection: string,
|
||||
user: User,
|
||||
): Promise<Comment> {
|
||||
if (comment.creatorId !== user.id) {
|
||||
throw new ForbiddenException(
|
||||
'You can only re-anchor your own suggestion',
|
||||
);
|
||||
}
|
||||
if (comment.parentCommentId) {
|
||||
throw new BadRequestException(
|
||||
'Only a top-level comment can carry a suggested edit',
|
||||
);
|
||||
}
|
||||
if (!comment.suggestedText) {
|
||||
throw new BadRequestException('This comment has no suggested edit');
|
||||
}
|
||||
// A settled suggestion's anchor is frozen: re-anchoring an applied/resolved
|
||||
// thread is meaningless and could resurrect a stale expectedText.
|
||||
if (comment.suggestionAppliedAt || comment.resolvedAt) {
|
||||
throw new BadRequestException(
|
||||
'Cannot re-anchor a suggestion that was already applied or resolved',
|
||||
);
|
||||
}
|
||||
const trimmed = selection.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new BadRequestException('The re-anchored selection cannot be empty');
|
||||
}
|
||||
// Same no-op guard as create(): the suggestion must differ from the text it
|
||||
// replaces, or apply becomes indistinguishable from already-applied.
|
||||
if (trimmed === comment.suggestedText.trim()) {
|
||||
throw new BadRequestException(
|
||||
'A suggested edit must differ from the selected text',
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent: nothing to persist when the anchor already matches.
|
||||
if (comment.selection === selection) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
await this.commentRepo.updateComment({ selection }, comment.id);
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
// Re-anchoring only corrects stored metadata; it does not change the page
|
||||
// text or the comment body, so no ws broadcast / notification is warranted.
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the suggested edit carried by a top-level inline comment: atomically
|
||||
* replace the text under the comment mark in the collaborative document with
|
||||
@@ -524,7 +594,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
});
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
}
|
||||
@@ -538,7 +608,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
@@ -577,8 +647,10 @@ export class CommentService {
|
||||
|
||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||
let didResolveBroadcast = false;
|
||||
if (!comment.resolvedAt) {
|
||||
await this.resolveComment(comment, true, user, provenance);
|
||||
didResolveBroadcast = true;
|
||||
}
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
@@ -586,18 +658,27 @@ export class CommentService {
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
});
|
||||
// #496 dedup: resolveComment already broadcast `commentResolved` carrying
|
||||
// the fully-enriched row (the applied stamps were persisted above, before
|
||||
// that call, so its re-read reflects them). Emitting `commentUpdated` here
|
||||
// too made the client receive TWO events for one apply. Broadcast the
|
||||
// update ONLY when we did NOT resolve — i.e. the rare re-entry on an
|
||||
// already-resolved thread, where the applied-stamp change still needs a
|
||||
// broadcast and resolveComment did not run.
|
||||
if (!didResolveBroadcast) {
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
});
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
});
|
||||
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
@@ -616,7 +697,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: { pageId: comment.pageId },
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
});
|
||||
|
||||
return settled;
|
||||
@@ -627,14 +708,17 @@ export class CommentService {
|
||||
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
|
||||
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
|
||||
*
|
||||
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
|
||||
* applySuggestion, which mutates the doc before writing the DB). The row
|
||||
* delete is irreversible, so if the mark removal fails — including the
|
||||
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
|
||||
* row and report success, or the document is left with a permanent orphan
|
||||
* anchor pointing at a comment that no longer exists (the exact data-integrity
|
||||
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
|
||||
* then repeatable with row + mark still consistent.
|
||||
* ORDER MATTERS (updated #399 → #496): what runs FIRST and FATALLY here is the
|
||||
* mark-removal ENQUEUE (a fast, durable Redis add), NOT the mark op itself.
|
||||
* deleteCommentMark awaits only the enqueue, so a failed add throws BEFORE the
|
||||
* irreversible row delete — the row + mark stay consistent and the operation is
|
||||
* repeatable. The actual anchor strip then runs off the HTTP path in the worker
|
||||
* (idempotent, 3 retries). Only an EXHAUSTED-retries job could leave the doc
|
||||
* with an orphan anchor pointing at a hard-deleted comment (the data-integrity
|
||||
* bug #329 targets); that residual divergence is now self-healed by the
|
||||
* resolve/unresolve mark worker, which strips an orphan mark whenever its
|
||||
* comment row is gone (#496), and it is meanwhile VISIBLE via BullMQ failed-job
|
||||
* metrics rather than a silently-swallowed warn.
|
||||
*
|
||||
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
|
||||
* removal, so a reply can land in that window. `comments.parent_comment_id` is
|
||||
@@ -732,6 +816,27 @@ export class CommentService {
|
||||
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
|
||||
* The subject comment is HARD-DELETED on the childless path, so the audit row
|
||||
* is the only surviving record — capture the decision's substance (what was
|
||||
* suggested, the anchored text it replaced, who authored it, who decided)
|
||||
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
|
||||
* is the suggestion's creator.
|
||||
*/
|
||||
private suggestionAuditMetadata(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
): Record<string, any> {
|
||||
return {
|
||||
pageId: comment.pageId,
|
||||
suggestedText: comment.suggestedText ?? null,
|
||||
selection: comment.selection ?? null,
|
||||
commentAuthor: comment.creatorId ?? null,
|
||||
decidedBy: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async queueCommentNotification(
|
||||
content: any,
|
||||
oldMentionIds: string[],
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* #496: after the MCP client anchors a suggestion in the LIVE collab doc, it
|
||||
* re-reads the exact substring under the new mark and syncs it here as the
|
||||
* comment's stored `selection` (== apply-time expectedText). Fixes the perpetual
|
||||
* 409 where expectedText came from a debounced REST snapshot while the mark sat
|
||||
* in the live doc.
|
||||
*/
|
||||
export class ResyncSuggestionAnchorDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
|
||||
// The raw substring the mark now covers in the live document. Bounded like the
|
||||
// create-time selection (2000) so a legitimate anchored span is never cut.
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
selection: string;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
|
||||
import { AUDIT_SERVICE } from './audit.service';
|
||||
import { DatabaseAuditService } from './database-audit.service';
|
||||
|
||||
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
|
||||
// which silently dropped every event). Kysely (@Global DatabaseModule) and
|
||||
// ClsService (@Global ClsModule) are both globally available, so this module
|
||||
// needs no extra imports.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
useClass: DatabaseAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class NoopAuditModule {}
|
||||
export class AuditModule {}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { DatabaseAuditService } from './database-audit.service';
|
||||
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
|
||||
/**
|
||||
* Observable-property coverage for the DB-backed audit trail (#496): every
|
||||
* assertion pins what actually reaches the `audit` table (or that nothing does),
|
||||
* driven through a chainable Kysely mock that captures the inserted rows.
|
||||
*/
|
||||
describe('DatabaseAuditService', () => {
|
||||
function makeService(clsContext: any) {
|
||||
const inserted: any[] = [];
|
||||
const updated: any[] = [];
|
||||
let failNextInsert = false;
|
||||
|
||||
const db: any = {
|
||||
insertInto: jest.fn(() => ({
|
||||
values: jest.fn((rows: any) => ({
|
||||
execute: jest.fn(async () => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('boom');
|
||||
}
|
||||
inserted.push(rows);
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
updateTable: jest.fn(() => ({
|
||||
set: jest.fn((patch: any) => ({
|
||||
where: jest.fn(() => ({
|
||||
execute: jest.fn(async () => {
|
||||
updated.push(patch);
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
|
||||
const cls: any = {
|
||||
get: jest.fn((key: string) => store[key]),
|
||||
set: jest.fn((key: string, val: any) => {
|
||||
store[key] = val;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new DatabaseAuditService(db, cls);
|
||||
return {
|
||||
service,
|
||||
inserted,
|
||||
updated,
|
||||
cls,
|
||||
store,
|
||||
failInsert: () => {
|
||||
failNextInsert = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const applyPayload = () => ({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
||||
});
|
||||
|
||||
const ctx = (over?: any) => ({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'u-2',
|
||||
actorType: 'user',
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'jest',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('log() persists a row with the CLS context merged onto the payload', async () => {
|
||||
const { service, inserted } = makeService(ctx());
|
||||
service.log(applyPayload());
|
||||
// log() is fire-and-forget; flush the microtask queue.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'u-2',
|
||||
actorType: 'user',
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
ipAddress: '10.0.0.1',
|
||||
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('log() is a no-op when there is no workspace in scope', async () => {
|
||||
const { service, inserted } = makeService(ctx({ workspaceId: null }));
|
||||
service.log(applyPayload());
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
|
||||
const { service, inserted } = makeService(ctx());
|
||||
service.log({
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a failed insert is swallowed with a warn and floats no rejection (audit is a side-record)', async () => {
|
||||
// This pins the load-bearing swallow inside persist(). Because log() is
|
||||
// fire-and-forget (`void this.persist(...)`), it always returns synchronously
|
||||
// without throwing — so `not.toThrow()` alone would stay green even if the
|
||||
// try/catch were removed. We instead observe the two effects the catch is
|
||||
// responsible for: a warn IS emitted, and NO unhandled rejection floats.
|
||||
// Removing persist()'s try/catch reddens both assertions (warn count 0 + a
|
||||
// captured rejection).
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as any);
|
||||
const rejections: unknown[] = [];
|
||||
const onRejection = (err: unknown) => rejections.push(err);
|
||||
process.on('unhandledRejection', onRejection);
|
||||
try {
|
||||
const { service, failInsert } = makeService(ctx());
|
||||
failInsert();
|
||||
expect(() => service.log(applyPayload())).not.toThrow();
|
||||
// Flush microtasks so the rejected insert settles, then give any floated
|
||||
// rejection a macrotask tick to be reported by the runtime.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain(
|
||||
'Failed to persist audit event',
|
||||
);
|
||||
expect(rejections).toHaveLength(0);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onRejection);
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logWithContext() persists with an explicit (non-request) context', async () => {
|
||||
const { service, inserted } = makeService(undefined);
|
||||
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0].actorType).toBe('system');
|
||||
expect(inserted[0].workspaceId).toBe('ws-1');
|
||||
});
|
||||
|
||||
it('logBatchWithContext() inserts only non-excluded events', async () => {
|
||||
const { service, inserted } = makeService(undefined);
|
||||
service.logBatchWithContext(
|
||||
[
|
||||
applyPayload(),
|
||||
{
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-2',
|
||||
},
|
||||
],
|
||||
ctx() as any,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Batch is a single insert call carrying only the applied event.
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]).toHaveLength(1);
|
||||
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
|
||||
});
|
||||
|
||||
it('setActorId / setActorType mutate the ambient CLS context', () => {
|
||||
const { service, store } = makeService(ctx({ actorId: null }));
|
||||
service.setActorId('u-9');
|
||||
service.setActorType('api_key');
|
||||
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
|
||||
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
|
||||
});
|
||||
|
||||
it('updateRetention() writes the workspace retention window', async () => {
|
||||
const { service, updated } = makeService(ctx());
|
||||
await service.updateRetention('ws-1', 30);
|
||||
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
AuditContext,
|
||||
AUDIT_CONTEXT_KEY,
|
||||
} from '../../common/middlewares/audit-context.middleware';
|
||||
import {
|
||||
AuditLogPayload,
|
||||
ActorType,
|
||||
EXCLUDED_AUDIT_EVENTS,
|
||||
} from '../../common/events/audit-events';
|
||||
import { AuditLogContext, IAuditService } from './audit.service';
|
||||
|
||||
/**
|
||||
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
|
||||
* decision-bearing events — notably comment.suggestion_applied /
|
||||
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
|
||||
* childless path — leave a durable record of who decided what. Without this the
|
||||
* events were emitted (comment.service / *.controller) but swallowed, so an
|
||||
* applied/dismissed suggestion was unrecoverable once the row was gone.
|
||||
*
|
||||
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
|
||||
* per-request actor/workspace/ip come from the CLS AuditContext populated by
|
||||
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
|
||||
* request (queue workers, imports) pass an explicit context via
|
||||
* logWithContext / logBatchWithContext.
|
||||
*
|
||||
* Audit is a side-record: a write failure MUST NOT break the originating
|
||||
* request, so every persistence path swallows its error with a warn. Events in
|
||||
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseAuditService implements IAuditService {
|
||||
private readonly logger = new Logger(DatabaseAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persist a single event using the ambient request-scoped AuditContext. A
|
||||
* no-op when there is no workspace in scope (the table's workspace_id is NOT
|
||||
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
|
||||
* awaited by hot callers, and its rejection is swallowed here.
|
||||
*/
|
||||
log(payload: AuditLogPayload): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (!context?.workspaceId) {
|
||||
// No workspace in scope — nothing we can attribute the row to. This is
|
||||
// expected for events emitted outside an HTTP request; those callers must
|
||||
// use logWithContext instead.
|
||||
return;
|
||||
}
|
||||
void this.persist(payload, {
|
||||
workspaceId: context.workspaceId,
|
||||
actorId: context.actorId ?? undefined,
|
||||
actorType: context.actorType,
|
||||
ipAddress: context.ipAddress ?? undefined,
|
||||
userAgent: context.userAgent ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a single event with an explicit (non-request) context. */
|
||||
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
|
||||
if (!context?.workspaceId) return;
|
||||
void this.persist(payload, context);
|
||||
}
|
||||
|
||||
/** Persist a batch of events sharing one explicit context (imports). */
|
||||
logBatchWithContext(
|
||||
payloads: AuditLogPayload[],
|
||||
context: AuditLogContext,
|
||||
): void {
|
||||
if (!context?.workspaceId || payloads.length === 0) return;
|
||||
const rows = payloads
|
||||
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
|
||||
.map((p) => this.toRow(p, context));
|
||||
if (rows.length === 0) return;
|
||||
this.db
|
||||
.insertInto('audit')
|
||||
.values(rows)
|
||||
.execute()
|
||||
.catch((err: any) =>
|
||||
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the ambient request actor (e.g. after login resolves the user). */
|
||||
setActorId(actorId: string): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (context) {
|
||||
context.actorId = actorId;
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the ambient request actor type (user | system | api_key). */
|
||||
setActorType(actorType: ActorType): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (context) {
|
||||
context.actorType = actorType;
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a workspace's audit-log retention window (days). */
|
||||
async updateRetention(
|
||||
workspaceId: string,
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.updateTable('workspaces')
|
||||
.set({ auditRetentionDays: retentionDays })
|
||||
.where('id', '=', workspaceId)
|
||||
.execute();
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(
|
||||
payload: AuditLogPayload,
|
||||
context: AuditLogContext,
|
||||
): Promise<void> {
|
||||
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
|
||||
try {
|
||||
await this.db
|
||||
.insertInto('audit')
|
||||
.values(this.toRow(payload, context))
|
||||
.execute();
|
||||
} catch (err: any) {
|
||||
// Audit is a side-record; never let a failed write surface to the caller.
|
||||
this.logger.warn(
|
||||
`Failed to persist audit event ${payload.event}: ${err?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
|
||||
return {
|
||||
workspaceId: context.workspaceId,
|
||||
actorId: context.actorId ?? null,
|
||||
actorType: context.actorType ?? 'user',
|
||||
event: payload.event,
|
||||
resourceType: payload.resourceType,
|
||||
resourceId: payload.resourceId ?? null,
|
||||
spaceId: payload.spaceId ?? null,
|
||||
// jsonb columns: node-postgres serializes plain objects to JSON.
|
||||
changes: payload.changes ? (payload.changes as any) : null,
|
||||
metadata: payload.metadata ? (payload.metadata as any) : null,
|
||||
ipAddress: context.ipAddress ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -139,13 +139,19 @@ describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('skips (no throw) when the comment row has vanished', async () => {
|
||||
it('reconcile (#496): comment row vanished → strips the orphan anchor mark', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
|
||||
).resolves.toBeUndefined();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
// A resolve/unresolve mark job whose comment row is gone leaves a silent
|
||||
// orphan; the worker self-heals by stripping the anchor instead of returning.
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,8 @@ export class GeneralQueueProcessor
|
||||
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
|
||||
* critical path. Runs the SAME gateway path the synchronous comment.service
|
||||
* code used (byte-identical mark op):
|
||||
* - resolve / unresolve → resolveCommentMark (flip the `resolved` attribute);
|
||||
* - resolve / unresolve → resolveCommentMark (flip the `resolved` attribute),
|
||||
* OR strip an orphan anchor when the comment row has vanished (#496);
|
||||
* - delete → deleteCommentMark (strip the ephemeral-suggestion anchor #329).
|
||||
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
|
||||
* WorkerHost → the job is retried and, on exhaustion, surfaces in failed-job
|
||||
@@ -133,7 +134,18 @@ export class GeneralQueueProcessor
|
||||
// of this resolve), skip it rather than flip the mark to a stale state.
|
||||
const comment = await this.commentRepo.findById(commentId);
|
||||
if (!comment) {
|
||||
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
|
||||
// #496 reconcile: the comment row is GONE (e.g. an ephemeral apply/dismiss
|
||||
// hard-deleted it while this resolve/unresolve mark job sat in the queue),
|
||||
// but its inline anchor may still live in the doc — a silent orphan mark
|
||||
// pointing at a comment that no longer exists. Self-heal by stripping it
|
||||
// instead of just returning: this closes the divergence the fire-and-forget
|
||||
// resolve/unresolve enqueue (comment.service resolveComment) could leave.
|
||||
// Idempotent — deleteCommentMark on an already-absent mark is a no-op.
|
||||
await this.getCollaborationGateway().handleYjsEvent(
|
||||
'deleteCommentMark',
|
||||
documentName,
|
||||
{ commentId, user },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const wantResolved = action === 'resolve';
|
||||
|
||||
@@ -450,6 +450,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||
// LIVE document (the pre-check page is not in scope there).
|
||||
let liveNotFoundError: Error | null = null;
|
||||
// #496: the RAW substring the mark actually covers in the LIVE doc. The
|
||||
// stored selection (payload.selection) came from a DEBOUNCED REST snapshot,
|
||||
// which can differ from the live doc — and apply compares the marked live
|
||||
// text to the stored selection strictly, so a stale snapshot 409s on EVERY
|
||||
// apply. Captured here (same doc version the mark is set in) and synced below.
|
||||
let liveAnchoredSelection: string | null = null;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
@@ -489,6 +495,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
}
|
||||
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
||||
anchored = true;
|
||||
// For a suggestion, re-read the exact substring now under the mark
|
||||
// (the mark is an attribute, so it does not change the raw text) to
|
||||
// sync as the stored expectedText after the mutation resolves.
|
||||
if (hasSuggestion) {
|
||||
liveAnchoredSelection = getAnchoredText(doc, selection as string);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
// Selection text not found in the LIVE document: abort the write. The
|
||||
@@ -527,6 +539,36 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
);
|
||||
}
|
||||
|
||||
// #496: sync the stored selection (== apply-time expectedText) to the RAW
|
||||
// substring the mark actually covers in the LIVE doc when it diverged from
|
||||
// the debounced REST snapshot we stored at create time. Without this, apply
|
||||
// strictly compares the marked live text to a stale stored selection and
|
||||
// 409s every time. Best-effort: the comment is already correctly anchored, so
|
||||
// a resync failure must NOT roll it back — it only risks a later apply 409,
|
||||
// which we surface as a soft warning.
|
||||
if (
|
||||
hasSuggestion &&
|
||||
liveAnchoredSelection != null &&
|
||||
liveAnchoredSelection !== payload.selection
|
||||
) {
|
||||
try {
|
||||
await this.client.post("/comments/resync-suggestion-anchor", {
|
||||
commentId: newCommentId,
|
||||
selection: liveAnchoredSelection,
|
||||
});
|
||||
// Reflect the corrected anchor in the returned comment.
|
||||
if (result.data) result.data.selection = liveAnchoredSelection;
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error("Failed to resync suggestion anchor:", e);
|
||||
}
|
||||
result.warning =
|
||||
"The suggestion was anchored, but its stored selection could not be " +
|
||||
"synced to the live document; applying it may report a conflict if the " +
|
||||
"text changed. Re-create the suggestion if Apply fails.";
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warning (like editPageText): the selection only matched after
|
||||
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||
if (anchorNormalized) {
|
||||
|
||||
@@ -553,6 +553,189 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
|
||||
assert.equal(createPayload.suggestedText, "goodbye");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8b) #496: the DEBOUNCED REST snapshot (pages/info) DIFFERS from the LIVE collab
|
||||
// doc (mutatePage) — the doc moved on in the debounce window. The stored
|
||||
// selection is captured from the snapshot at create time, but the mark is set
|
||||
// in the live doc, so apply would 409 forever. After anchoring, the client
|
||||
// re-reads the RAW substring under the mark from the LIVE doc and POSTs it to
|
||||
// /comments/resync-suggestion-anchor so the stored expectedText matches.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestion: re-syncs the stored selection to the LIVE doc substring when the REST snapshot lagged", async () => {
|
||||
let createPayload = null;
|
||||
let resyncPayload = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// DEBOUNCED snapshot: ASCII quotes (stale — the live doc has since been
|
||||
// typographically corrected).
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "33333333-3333-3333-3333-333333333333",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: 'he said "hello" loudly' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createPayload = JSON.parse(raw);
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "cmt-resync-1",
|
||||
content: createPayload.content,
|
||||
selection: createPayload.selection,
|
||||
suggestedText: createPayload.suggestedText,
|
||||
type: createPayload.type,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/resync-suggestion-anchor") {
|
||||
resyncPayload = JSON.parse(raw);
|
||||
sendJson(res, 200, { data: { id: "cmt-resync-1" } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
class TestClient extends DocmostClient {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId() {
|
||||
return "33333333-3333-3333-3333-333333333333";
|
||||
}
|
||||
async mutatePage(pageId, collabToken, apiUrl, transform) {
|
||||
// LIVE doc: SMART quotes (what the mark is actually set over).
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "he said “hello” loudly" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = transform(doc);
|
||||
return { doc: out, verify: { ok: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.createComment(
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
"please change",
|
||||
"inline",
|
||||
'"hello"', // ASCII quotes
|
||||
undefined,
|
||||
"goodbye",
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.anchored, true);
|
||||
// Create stored the STALE snapshot substring (ASCII quotes).
|
||||
assert.equal(createPayload.selection, '"hello"');
|
||||
// …then the client re-synced to the LIVE marked substring (smart quotes).
|
||||
assert.ok(resyncPayload, "/comments/resync-suggestion-anchor must be called");
|
||||
assert.equal(resyncPayload.commentId, "cmt-resync-1");
|
||||
assert.equal(resyncPayload.selection, "“hello”");
|
||||
// The returned comment reflects the corrected anchor.
|
||||
assert.equal(result.data.selection, "“hello”");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8c) #496: when the REST snapshot and the LIVE doc AGREE, no resync round-trip
|
||||
// is made (the common case must stay a single write).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestion: no resync call when the snapshot already matches the live doc", async () => {
|
||||
let resyncCalls = 0;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "44444444-4444-4444-4444-444444444444",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
const p = JSON.parse(raw);
|
||||
sendJson(res, 200, {
|
||||
data: { id: "cmt-nosync-1", content: p.content, selection: p.selection, suggestedText: p.suggestedText, type: p.type },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/resync-suggestion-anchor") {
|
||||
resyncCalls++;
|
||||
sendJson(res, 200, { data: {} });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
class TestClient extends DocmostClient {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId() {
|
||||
return "44444444-4444-4444-4444-444444444444";
|
||||
}
|
||||
async mutatePage(pageId, collabToken, apiUrl, transform) {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
};
|
||||
const out = transform(doc);
|
||||
return { doc: out, verify: { ok: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.createComment(
|
||||
"44444444-4444-4444-4444-444444444444",
|
||||
"rename",
|
||||
"inline",
|
||||
"brave",
|
||||
undefined,
|
||||
"bold",
|
||||
);
|
||||
|
||||
assert.equal(result.anchored, true);
|
||||
assert.equal(resyncCalls, 0, "matching snapshot must NOT trigger a resync round-trip");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8) #408: a not-found selection error QUOTES the closest block text so the
|
||||
// model can self-correct instead of blind-retrying.
|
||||
|
||||
Reference in New Issue
Block a user