vault-link/frontend/deterministic-tests/src/tests/stale-doc-orphan-duplicate-content.test.ts

122 lines
4.9 KiB
TypeScript

import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: Stale doc kept on disk creates duplicate content after create-merge.
*
* Found by: E2E test log analysis (log.log, process 672773)
*
* Root cause sequence:
* 1. Client 1 has document D1 tracked at path "target.md"
* 2. Client 0 renames D1 to "moved.md" on the server
* 3. Client 1 (offline) creates a new file at "moved.md"
* 4. Client 1 reconnects — the create is sent to the server
* 5. Server merges the create with D1 (at "moved.md") → MergingUpdate with D1
* 6. ensureUniqueDocumentId finds D1 at "target.md" → stale doc
* 7. "target.md" was locally modified during the create's HTTP request
* → hasLocalChanges = true → file kept on disk, VFS record removed
* 8. On the next reconciliation, orphaned "target.md" is re-synced
* as a new document. Now BOTH "target.md" and "moved.md" contain
* the original content from D1 — violating the content-uniqueness
* invariant.
*
* The server pause is used to keep the create HTTP request in-flight
* while the local file at D1's old path is modified (step 7).
*/
function verifyNoDuplicateContent(state: ClientState): void {
const entries = [...state.files.entries()];
// The word "original" was D1's initial content. After the create-merge,
// it should appear in at most ONE file. If the stale orphan was re-synced
// as a separate document, "original" will appear in multiple files.
const filesContainingOriginal = entries.filter(([, content]) =>
content.includes("original")
);
assert(
filesContainingOriginal.length <= 1,
`Content "original" found in ${filesContainingOriginal.length} files: ` +
`${filesContainingOriginal.map(([p]) => p).join(", ")}. ` +
`This means the stale doc orphan was re-synced, creating duplicate content.\n` +
`Files:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
);
}
export const staleDocOrphanDuplicateContentTest: TestDefinition = {
name: "Stale Doc Orphan Creates Duplicate Content After Create-Merge",
description:
"When a create merges with an existing document, the stale VFS " +
"record is removed but the file is kept on disk (local changes). " +
"If the orphaned file is later re-synced as a new document, the " +
"original content appears in multiple files.",
clients: 2,
steps: [
// ── Setup: both clients share D1 at "target.md" ──
{ type: "create", client: 0, path: "target.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// ── Client 1 goes offline ──
{ type: "disable-sync", client: 1 },
// ── Client 0 renames the document to a new path ──
// Server now has D1 at "moved.md"
{
type: "rename",
client: 0,
oldPath: "target.md",
newPath: "moved.md"
},
{ type: "sync", client: 0 },
// ── Client 1 (offline) creates a file at D1's new server path ──
// Client 1 doesn't know D1 was renamed there.
{
type: "create",
client: 1,
path: "moved.md",
content: "unrelated-content"
},
// ── Pause server to stall the create HTTP request ──
{ type: "pause-server" },
// ── Enable sync on client 1 ──
// scheduleSyncForOfflineChanges runs:
// "target.md": D1, hash matches → no update
// "moved.md": no metadata → create scheduled
// The create HTTP request stalls (server frozen).
// enableSync waits up to 10 s for WebSocket then returns.
{ type: "enable-sync", client: 1 },
// ── Modify D1's old path while the create is in-flight ──
// This makes hasLocalChanges = true when ensureUniqueDocumentId
// checks the stale doc at "target.md".
{
type: "update",
client: 1,
path: "target.md",
content: "original extra-edit"
},
// ── Resume server ──
// Create completes: server merges with D1 → MergingUpdate
// ensureUniqueDocumentId: D1 at "target.md" → stale doc
// hasLocalChanges("target.md"): "original extra-edit" ≠ "original" → true
// File kept, VFS record removed.
//
// WebSocket connects → second reconciliation detects orphaned
// "target.md" → re-synced as new document → DUPLICATE CONTENT.
{ type: "resume-server" },
// ── Settle ──
{ type: "sync" },
{ type: "sync" },
{ type: "barrier" },
// ── Verify: "original" must not appear in multiple files ──
{ type: "assert-consistent", verify: verifyNoDuplicateContent }
]
};