refactor: sync simplification (#752)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
492
src/tests/agent/memoryFilesystem.sync.integration.test.ts
Normal file
492
src/tests/agent/memoryFilesystem.sync.integration.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Integration tests for memory filesystem sync behavior.
|
||||
* These tests hit the real Letta API and require LETTA_API_KEY to be set.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Bug 1: File move from system/ to root/ (should detach, not duplicate)
|
||||
* - Bug 2: File deletion (should remove owner tag, not resurrect)
|
||||
* - FS wins all policy (when both changed, file wins)
|
||||
* - Location mismatch auto-sync
|
||||
*
|
||||
* Run with: bun test src/tests/agent/memoryFilesystem.sync.integration.test.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
|
||||
import {
|
||||
checkMemoryFilesystemStatus,
|
||||
ensureMemoryFilesystemDirs,
|
||||
getMemoryDetachedDir,
|
||||
getMemorySystemDir,
|
||||
syncMemoryFilesystem,
|
||||
} from "../../agent/memoryFilesystem";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
|
||||
// Skip all tests if no API key is available
|
||||
const LETTA_API_KEY = process.env.LETTA_API_KEY;
|
||||
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || "https://api.letta.com";
|
||||
|
||||
const describeIntegration = LETTA_API_KEY ? describe : describe.skip;
|
||||
|
||||
describeIntegration("memfs sync integration", () => {
|
||||
let client: Letta;
|
||||
let testAgentId: string;
|
||||
let tempHomeDir: string;
|
||||
let originalHome: string | undefined;
|
||||
const createdBlockIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new Letta({
|
||||
baseURL: LETTA_BASE_URL,
|
||||
apiKey: LETTA_API_KEY!,
|
||||
});
|
||||
|
||||
// Create a test agent
|
||||
const agent = await client.agents.create({
|
||||
name: `memfs-sync-test-${Date.now()}`,
|
||||
model: "letta/letta-free",
|
||||
embedding: "letta/letta-free",
|
||||
});
|
||||
testAgentId = agent.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up: delete created blocks
|
||||
for (const blockId of createdBlockIds) {
|
||||
try {
|
||||
await client.blocks.delete(blockId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test agent
|
||||
if (testAgentId) {
|
||||
try {
|
||||
await client.agents.delete(testAgentId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset settings manager before changing HOME
|
||||
await settingsManager.reset();
|
||||
|
||||
// Create temp directory and override HOME
|
||||
tempHomeDir = join(tmpdir(), `memfs-sync-test-${Date.now()}`);
|
||||
mkdirSync(tempHomeDir, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHomeDir;
|
||||
|
||||
// Create settings with API base URL
|
||||
// API key is read from process.env.LETTA_API_KEY by getClient()
|
||||
const settingsDir = join(tempHomeDir, ".letta");
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
env: {
|
||||
LETTA_BASE_URL: LETTA_BASE_URL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize settings manager with new HOME
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Set up memfs directories
|
||||
ensureMemoryFilesystemDirs(testAgentId, tempHomeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Reset settings manager
|
||||
await settingsManager.reset();
|
||||
|
||||
// Restore HOME
|
||||
process.env.HOME = originalHome;
|
||||
|
||||
// Clean up temp directory
|
||||
if (tempHomeDir && existsSync(tempHomeDir)) {
|
||||
rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function getSystemDir(): string {
|
||||
return getMemorySystemDir(testAgentId, tempHomeDir);
|
||||
}
|
||||
|
||||
function getDetachedDir(): string {
|
||||
return getMemoryDetachedDir(testAgentId, tempHomeDir);
|
||||
}
|
||||
|
||||
function writeSystemFile(label: string, content: string): void {
|
||||
const systemDir = getSystemDir();
|
||||
const filePath = join(systemDir, `${label}.md`);
|
||||
const dir = join(systemDir, ...label.split("/").slice(0, -1));
|
||||
if (label.includes("/")) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeDetachedFile(label: string, content: string): void {
|
||||
const detachedDir = getDetachedDir();
|
||||
const filePath = join(detachedDir, `${label}.md`);
|
||||
const dir = join(detachedDir, ...label.split("/").slice(0, -1));
|
||||
if (label.includes("/")) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function deleteFile(dir: string, label: string): void {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
rmSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function readFile(dir: string, label: string): string | null {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
return readFileSync(filePath, "utf-8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAttachedBlocks(): Promise<
|
||||
Array<{ id: string; label?: string; value?: string }>
|
||||
> {
|
||||
const blocks = await client.agents.blocks.list(testAgentId);
|
||||
return Array.isArray(blocks)
|
||||
? blocks
|
||||
: (
|
||||
blocks as {
|
||||
items?: Array<{ id: string; label?: string; value?: string }>;
|
||||
}
|
||||
).items || [];
|
||||
}
|
||||
|
||||
async function getOwnedBlocks(): Promise<
|
||||
Array<{ id: string; label?: string; value?: string; tags?: string[] }>
|
||||
> {
|
||||
const ownerTag = `owner:${testAgentId}`;
|
||||
const blocks = await client.blocks.list({ tags: [ownerTag] });
|
||||
return Array.isArray(blocks)
|
||||
? blocks
|
||||
: (
|
||||
blocks as {
|
||||
items?: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}
|
||||
).items || [];
|
||||
}
|
||||
|
||||
test("new file in system/ creates attached block", async () => {
|
||||
const label = `test-new-file-${Date.now()}`;
|
||||
const content = "New file content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// Sync
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block was created
|
||||
expect(result.createdBlocks).toContain(label);
|
||||
|
||||
// Verify block is attached
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
expect(block?.value).toBe(content);
|
||||
|
||||
// Track for cleanup
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("new file at root creates detached block (not attached)", async () => {
|
||||
const label = `test-detached-${Date.now()}`;
|
||||
const content = "Detached file content";
|
||||
|
||||
// Create file at root (detached)
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Sync
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block was created
|
||||
expect(result.createdBlocks).toContain(label);
|
||||
|
||||
// Verify block is NOT attached
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const attachedBlock = attachedBlocks.find((b) => b.label === label);
|
||||
expect(attachedBlock).toBeUndefined();
|
||||
|
||||
// Verify block exists via owner tag (detached)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const ownedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(ownedBlock).toBeDefined();
|
||||
expect(ownedBlock?.value).toBe(content);
|
||||
|
||||
// Track for cleanup
|
||||
if (ownedBlock?.id) {
|
||||
createdBlockIds.push(ownedBlock.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("file move from system/ to root/ detaches block (no duplication)", async () => {
|
||||
const label = `test-move-${Date.now()}`;
|
||||
const content = "Content that will be moved";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is attached
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
|
||||
// Move file: delete from system/, create at root
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Second sync - should detach (location mismatch with same content)
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is no longer attached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify only ONE block exists with this label (no duplication)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const matchingBlocks = ownedBlocks.filter((b) => b.label === label);
|
||||
expect(matchingBlocks.length).toBe(1);
|
||||
|
||||
// Verify the block still exists (just detached)
|
||||
expect(matchingBlocks[0]?.value).toBe(content);
|
||||
});
|
||||
|
||||
test("file deletion removes owner tag (no resurrection)", async () => {
|
||||
const label = `test-delete-${Date.now()}`;
|
||||
const content = "Content that will be deleted";
|
||||
|
||||
// Create file at root (detached)
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// First sync - creates detached block with owner tag
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block exists via owner tag
|
||||
let ownedBlocks = await getOwnedBlocks();
|
||||
let block = ownedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
deleteFile(getDetachedDir(), label);
|
||||
|
||||
// Second sync - should remove owner tag
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
expect(result.deletedBlocks).toContain(label);
|
||||
|
||||
// Verify block no longer has owner tag (not discoverable)
|
||||
ownedBlocks = await getOwnedBlocks();
|
||||
block = ownedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Third sync - file should NOT resurrect
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
const fileContent = readFile(getDetachedDir(), label);
|
||||
expect(fileContent).toBeNull();
|
||||
});
|
||||
|
||||
test("FS wins all: when both file and block changed, file wins", async () => {
|
||||
const label = `test-fs-wins-${Date.now()}`;
|
||||
const originalContent = "Original content";
|
||||
const fileContent = "File changed content";
|
||||
const blockContent = "Block changed content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, originalContent);
|
||||
|
||||
// First sync - creates block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Change both file AND block
|
||||
writeSystemFile(label, fileContent);
|
||||
await client.blocks.update(blockId!, { value: blockContent });
|
||||
|
||||
// Second sync - file should win (no conflict)
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify no conflicts
|
||||
expect(result.conflicts.length).toBe(0);
|
||||
expect(result.updatedBlocks).toContain(label);
|
||||
|
||||
// Verify block has FILE content (not block content)
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block?.value).toBe(fileContent);
|
||||
});
|
||||
|
||||
test("location mismatch auto-sync: content matches but location differs", async () => {
|
||||
const label = `test-location-${Date.now()}`;
|
||||
const content = "Same content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Move file to root (content unchanged)
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Second sync - should detach block (location mismatch with same content)
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is no longer attached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify block still exists (detached)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const detachedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(detachedBlock).toBeDefined();
|
||||
});
|
||||
|
||||
test("location mismatch with content diff: sync both in one pass", async () => {
|
||||
const label = `test-location-content-${Date.now()}`;
|
||||
const originalContent = "Original content";
|
||||
const newContent = "New content at root";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, originalContent);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Move file to root AND change content
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, newContent);
|
||||
|
||||
// Second sync - should update content AND detach in one pass
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block content was updated
|
||||
expect(result.updatedBlocks).toContain(label);
|
||||
|
||||
// Verify block is detached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify detached block has new content
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const detachedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(detachedBlock).toBeDefined();
|
||||
expect(detachedBlock?.value).toBe(newContent);
|
||||
});
|
||||
|
||||
test("checkMemoryFilesystemStatus reports location mismatches", async () => {
|
||||
const label = `test-status-${Date.now()}`;
|
||||
const content = "Status test content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const block = attachedBlocks.find((b) => b.label === label);
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
|
||||
// Move file to root (content unchanged)
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Check status - should report location mismatch
|
||||
const status = await checkMemoryFilesystemStatus(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
expect(status.locationMismatches).toContain(label);
|
||||
expect(status.isClean).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -512,3 +512,190 @@ describe("block tagging", () => {
|
||||
expect(updatedBlocks.map((u) => u.blockId)).not.toContain("block-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync behavior - location mismatch handling", () => {
|
||||
test("file move from system/ to root/ should detach block, not create duplicate", () => {
|
||||
// Bug fix test: When a file moves from system/ to root/
|
||||
//
|
||||
// Before fix:
|
||||
// 1. Sync builds detachedBlockMap from owner-tagged blocks
|
||||
// 2. System loop: "file missing in system/, block exists" → detaches block
|
||||
// 3. Detached loop: "file exists at root/, no block in detachedBlockMap"
|
||||
// → Creates NEW block (duplicate!)
|
||||
//
|
||||
// After fix:
|
||||
// 1. System loop detaches the block
|
||||
// 2. System loop adds the detached block to detachedBlockMap
|
||||
// 3. Detached loop sees both file AND block → syncs them correctly
|
||||
|
||||
// The fix ensures no duplicate blocks are created on file move
|
||||
const scenario = {
|
||||
before: {
|
||||
systemFile: "persona.md",
|
||||
attachedBlock: "persona",
|
||||
},
|
||||
action: "mv system/persona.md root/persona.md",
|
||||
after: {
|
||||
detachedFile: "persona.md",
|
||||
// Block should be detached, NOT duplicated
|
||||
expectedBlockCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(scenario.after.expectedBlockCount).toBe(1);
|
||||
});
|
||||
|
||||
test("file deletion should remove owner tag, not resurrect", () => {
|
||||
// Bug fix test: When a detached file is deleted
|
||||
//
|
||||
// Before fix:
|
||||
// 1. User deletes root/notes.md
|
||||
// 2. Sync: "file missing, block exists" → untracks from state only
|
||||
// 3. Next sync: "block exists (via owner tag), file missing" → recreates file!
|
||||
//
|
||||
// After fix:
|
||||
// 1. User deletes root/notes.md
|
||||
// 2. Sync: "file missing, block exists" → removes owner tag from block
|
||||
// 3. Next sync: block no longer discovered via owner tag → file stays deleted
|
||||
|
||||
const scenario = {
|
||||
before: {
|
||||
detachedFile: "notes.md",
|
||||
detachedBlock: { id: "block-1", tags: ["owner:agent-123"] },
|
||||
},
|
||||
action: "rm root/notes.md",
|
||||
after: {
|
||||
// Block should have owner tag removed
|
||||
expectedTags: [],
|
||||
// File should NOT resurrect
|
||||
fileExists: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(scenario.after.fileExists).toBe(false);
|
||||
expect(scenario.after.expectedTags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync state migration", () => {
|
||||
test("legacy state format is migrated to unified format", () => {
|
||||
// The new SyncState uses blockHashes/fileHashes instead of
|
||||
// systemBlocks/systemFiles/detachedBlocks/detachedFiles
|
||||
//
|
||||
// loadSyncState should detect and migrate the legacy format
|
||||
|
||||
// Legacy format (what old state files look like):
|
||||
// {
|
||||
// systemBlocks: { persona: "hash1" },
|
||||
// systemFiles: { persona: "hash1" },
|
||||
// detachedBlocks: { notes: "hash2" },
|
||||
// detachedFiles: { notes: "hash2" },
|
||||
// detachedBlockIds: { notes: "block-123" },
|
||||
// lastSync: "2024-01-01T00:00:00.000Z",
|
||||
// }
|
||||
|
||||
// After migration, should be unified:
|
||||
const expectedMigratedState = {
|
||||
blockHashes: { persona: "hash1", notes: "hash2" },
|
||||
fileHashes: { persona: "hash1", notes: "hash2" },
|
||||
blockIds: { notes: "block-123" },
|
||||
lastSync: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
// The migration merges system + detached into unified maps
|
||||
expect(Object.keys(expectedMigratedState.blockHashes)).toHaveLength(2);
|
||||
expect(Object.keys(expectedMigratedState.fileHashes)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FS wins all policy", () => {
|
||||
test("when both file and block changed, file wins (no conflict)", () => {
|
||||
// "FS wins all" policy: if file was touched (moved or edited), file version wins
|
||||
//
|
||||
// Before this policy:
|
||||
// - Both changed → CONFLICT (agent must resolve)
|
||||
//
|
||||
// After this policy:
|
||||
// - Both changed → file wins, block is updated from file (no conflict)
|
||||
//
|
||||
// Rationale: if someone is actively working with memfs locally,
|
||||
// they're in "local mode" and FS state is their intent
|
||||
|
||||
const scenario = {
|
||||
fileChanged: true,
|
||||
blockChanged: true,
|
||||
expectedResult: "file wins, block updated",
|
||||
conflictCreated: false,
|
||||
};
|
||||
|
||||
expect(scenario.conflictCreated).toBe(false);
|
||||
expect(scenario.expectedResult).toBe("file wins, block updated");
|
||||
});
|
||||
|
||||
test("explicit resolution=block can override FS wins policy", () => {
|
||||
// Even with "FS wins all", explicit resolutions are respected
|
||||
// If user provides resolution.resolution === "block", block wins
|
||||
|
||||
const scenario = {
|
||||
fileChanged: true,
|
||||
blockChanged: true,
|
||||
resolution: { resolution: "block" },
|
||||
expectedResult: "block wins, file updated",
|
||||
};
|
||||
|
||||
expect(scenario.expectedResult).toBe("block wins, file updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("location mismatch auto-sync", () => {
|
||||
test("content matches but location mismatches → auto-sync attachment", () => {
|
||||
// When file and block have same content but location doesn't match:
|
||||
// - File at root, block attached → detach block
|
||||
// - File in system/, block detached → attach block
|
||||
//
|
||||
// This implements "FS location is authoritative for attachment status"
|
||||
|
||||
const scenario = {
|
||||
fileLocation: "root", // file at root/
|
||||
blockAttached: true, // block is attached
|
||||
contentMatches: true,
|
||||
expectedAction: "detach block to match file location",
|
||||
};
|
||||
|
||||
expect(scenario.expectedAction).toBe("detach block to match file location");
|
||||
});
|
||||
|
||||
test("file in system/ with detached block → attach block", () => {
|
||||
const scenario = {
|
||||
fileLocation: "system",
|
||||
blockAttached: false,
|
||||
contentMatches: true,
|
||||
expectedAction: "attach block to match file location",
|
||||
};
|
||||
|
||||
expect(scenario.expectedAction).toBe("attach block to match file location");
|
||||
});
|
||||
|
||||
test("content differs AND location mismatches → sync both in one pass", () => {
|
||||
// "FS wins all" applies to both content AND location
|
||||
// When content differs and location mismatches:
|
||||
// 1. File content wins → block updated
|
||||
// 2. File location wins → attachment status synced
|
||||
// Both happen in the SAME sync (not requiring two syncs)
|
||||
|
||||
const scenario = {
|
||||
fileLocation: "root",
|
||||
blockAttached: true,
|
||||
fileContent: "new content",
|
||||
blockContent: "old content",
|
||||
expectedActions: [
|
||||
"update block content from file",
|
||||
"detach block to match file location",
|
||||
],
|
||||
requiresTwoSyncs: false, // Fixed! Previously required 2 syncs
|
||||
};
|
||||
|
||||
expect(scenario.requiresTwoSyncs).toBe(false);
|
||||
expect(scenario.expectedActions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user