Files
bDS/tests/engine/MetadataDiffEngine.test.ts
Georg Bauer b855d61524 Feature/post media translations (#42)
* chore: updated todo with translation ideas

* feat: first take at the implementation of translations

* fix: small addition for the translation feature

* feat: support language switching in the editor and preview

* feat: better handling of long bodies by not running them through a json envelope

* fix: unknown macros have better fallback

* feat: api for python to get translations

* fix: strip dumb prefix of content in translation

* feat: extend meta diff for translations

* feat: hook up translations to rebuild-from-disk

* feat: generation of the website prefers project language, falling back to canonical language

* fix: crashes during rendering

* feat: translation validation report

* fix: made the translation validation actually work

* chore: reorganization of menu

* fix: some topics cleanup

* chore: updated doc

* feat: translations for media

* feat: more aligned in UI/UX

* feat: edit translations possible

* chore: added full multi-language todo

* chore: updated todo for clarity

* feat: implementation of full multi-linguality

* fix: page creation creates pages

* fix: flags on every page

* fix: better prompt

* feat: made MCP server aware of language content

* feat: python tools for translations

* fix: better fill-in-translations

* fix: better prompt for translation. maybe.

* fix: losing posts from search due to translation process

* fix: translation validation handles in-db content and fill-in of missing translations fixed to flush

* fix: faster scanning for infilling of missing translations

* chore: updated agent instructions

* feat: calendar and tag cloud respect current language now

* fix: retries going up

* fix: got metadata-diff and rebuild into sync

* fix: extended meta-diff for timestamps

* fix: made website validation look at translated content, too

* fix: multi-lingual search

* chore: refactor Editor.tsx into two separate editors

* feat: do language detection when no explicit language given

---------

Co-authored-by: hugo <hugoms@me.com>
2026-03-09 14:43:18 +01:00

2174 lines
70 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetadataDiffEngine Unit Tests
*
* Tests the REAL MetadataDiffEngine class with mocked dependencies.
* Following TDD best practices: mock external dependencies, test real implementation.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
MetadataDiffEngine, PostMetadataDiff, DiffGroup, DiffField,
MediaMetadataDiff, MediaDiffField,
ScriptMetadataDiff, ScriptDiffField,
TemplateMetadataDiff, TemplateDiffField,
OrphanFile,
} from '../../src/main/engine/MetadataDiffEngine';
import { resetMockCounters } from '../utils/factories';
// Mock posts data store - used for single-item .get() queries
const mockPosts = new Map<string, any>();
// Queue of posts for sequential .get() calls (used in scanAllPublishedPosts)
let mockPostsGetQueue: any[] = [];
let mockAllPostsRows: any[] = [];
// Create chainable mock for Drizzle ORM
function createSelectChain(data: any[] = []) {
const chain: any = {
from: vi.fn().mockImplementation(() => chain),
where: vi.fn().mockImplementation(() => chain),
orderBy: vi.fn().mockImplementation(() => chain),
limit: vi.fn().mockImplementation(() => chain),
offset: vi.fn().mockImplementation(() => chain),
all: vi.fn().mockResolvedValue(data),
get: vi.fn().mockImplementation(() => {
// If there are queued posts, return from queue
if (mockPostsGetQueue.length > 0) {
return Promise.resolve(mockPostsGetQueue.shift());
}
// Otherwise return from map
return Promise.resolve(mockPosts.size > 0 ? Array.from(mockPosts.values())[0] : undefined);
}),
then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => {
return Promise.resolve(data).then(resolve, reject);
},
};
return chain;
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain(mockAllPostsRows)),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
};
}
const mockLocalDb = createDrizzleMock();
const mockLocalClient = {
execute: vi.fn(async () => ({ rows: [] })),
};
// Mock the database module
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
})),
}));
// Mock file contents for readPostFile
const mockFileData = new Map<string, any>();
// Mock fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(async (path: string) => {
const data = mockFileData.get(path);
if (!data) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return data;
}),
writeFile: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
readdir: vi.fn(async () => []),
access: vi.fn(async (path: string) => {
if (!mockFileData.has(path)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}),
stat: vi.fn(async () => ({
isFile: () => true,
isDirectory: () => false,
})),
}));
// Mock gray-matter
vi.mock('gray-matter', () => ({
default: vi.fn((content: string) => {
// Simple mock that extracts frontmatter
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { data: {}, content };
// Parse YAML-like frontmatter
const frontmatter = match[1];
const body = match[2];
const data: any = {};
frontmatter.split('\n').forEach(line => {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
let value: any = line.slice(colonIndex + 1).trim();
// Parse arrays
if (value.startsWith('[') && value.endsWith(']')) {
value = JSON.parse(value.replace(/'/g, '"'));
}
// Parse booleans
else if (value === 'true') {
value = true;
}
else if (value === 'false') {
value = false;
}
// Parse strings
else if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
data[key] = value;
}
});
return { data, content: body };
}),
}));
// Mock electron app
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/mock/userData'),
},
}));
// Mock TaskManager
vi.mock('../../src/main/engine/TaskManager', () => ({
taskManager: {
runTask: vi.fn(async (task: any) => {
return task.execute((progress: number, message: string) => {});
}),
},
}));
// Track the mock function for PostEngine.syncPublishedPostFile
const mockSyncPublishedPostFile = vi.fn(async () => true);
const mockSyncPublishedPostTranslationFile = vi.fn(async () => true);
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
const mockImportOrphanTranslationFile = vi.fn(async () => ({ id: 'imported-translation-id', title: 'Imported translation' }));
// Mock PostEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
})),
}));
describe('MetadataDiffEngine', () => {
let engine: MetadataDiffEngine;
beforeEach(() => {
vi.clearAllMocks();
mockPosts.clear();
mockPostsGetQueue = [];
mockFileData.clear();
mockAllPostsRows = [];
mockLocalDb.select.mockImplementation(() => createSelectChain(mockAllPostsRows));
mockLocalDb.update.mockImplementation(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
}) as any);
mockSyncPublishedPostFile.mockReset().mockResolvedValue(true);
mockSyncPublishedPostTranslationFile.mockReset().mockResolvedValue(true);
mockImportOrphanFile.mockReset().mockResolvedValue({ id: 'imported-id', title: 'Imported' });
mockImportOrphanTranslationFile.mockReset().mockResolvedValue({ id: 'imported-translation-id', title: 'Imported translation' });
resetMockCounters();
engine = new MetadataDiffEngine({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
} as any);
engine.setProjectContext('test-project');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create a new MetadataDiffEngine instance', () => {
expect(engine).toBeDefined();
expect(engine).toBeInstanceOf(MetadataDiffEngine);
});
});
describe('setProjectContext', () => {
it('should set the current project ID', () => {
engine.setProjectContext('project-123');
expect(engine.getProjectContext()).toBe('project-123');
});
});
describe('comparePostMetadata', () => {
it('should return null for draft posts (no file)', async () => {
// Set up a draft post in database
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Draft Post',
slug: 'draft-post',
status: 'draft',
filePath: null,
tags: '["tag1"]',
categories: '["cat1"]',
createdAt: new Date(),
updatedAt: new Date(),
};
mockPosts.set('post-1', dbPost);
const result = await engine.comparePostMetadata('post-1');
expect(result).toBeNull();
});
it('should detect tag differences between DB and file', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '["tag1", "tag2"]',
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
// DB has tag2 but file doesn't
mockPosts.set('post-1', dbPost);
// File has different tags
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["tag1", "old-tag"]
categories: ["cat1"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.tags).toBeDefined();
expect(result?.differences.tags?.dbValue).toEqual(['tag1', 'tag2']);
expect(result?.differences.tags?.fileValue).toEqual(['tag1', 'old-tag']);
});
it('should detect category differences between DB and file', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '["cat1", "cat2"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: ["cat1"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.categories).toBeDefined();
expect(result?.differences.categories?.dbValue).toEqual(['cat1', 'cat2']);
expect(result?.differences.categories?.fileValue).toEqual(['cat1']);
});
it('should detect language differences between DB and file', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'en',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
language: fr
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('en');
expect(result?.differences.language?.fileValue).toBe('fr');
});
it('should detect missing language in file when DB has language', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'de',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('de');
expect(result?.differences.language?.fileValue).toBe('');
});
it('should populate differences with DB values when file is missing', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/non-existent.md',
tags: '["tag1", "tag2"]',
categories: '["cat1"]',
excerpt: 'Some excerpt',
author: 'Author Name',
language: 'de',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
// File intentionally NOT added to mockFileData → readPostFile returns null
mockPosts.set('post-1', dbPost);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.fileMissing).toBe(true);
// DB values should appear in differences so the UI shows what fields exist
expect(result?.differences.tags).toEqual({ dbValue: ['tag1', 'tag2'], fileValue: null });
expect(result?.differences.categories).toEqual({ dbValue: ['cat1'], fileValue: null });
expect(result?.differences.title).toEqual({ dbValue: 'Published Post', fileValue: null });
expect(result?.differences.excerpt).toEqual({ dbValue: 'Some excerpt', fileValue: null });
expect(result?.differences.author).toEqual({ dbValue: 'Author Name', fileValue: null });
expect(result?.differences.language).toEqual({ dbValue: 'de', fileValue: null });
});
it('should omit empty DB fields from differences when file is missing', async () => {
const dbPost = {
id: 'post-2',
projectId: 'test-project',
title: 'Minimal Post',
slug: 'minimal-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/gone.md',
tags: '[]',
categories: '[]',
excerpt: null,
author: null,
language: null,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
};
mockPosts.set('post-2', dbPost);
const result = await engine.comparePostMetadata('post-2');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.fileMissing).toBe(true);
// Title always present since it's non-null
expect(result?.differences.title).toEqual({ dbValue: 'Minimal Post', fileValue: null });
// Empty arrays / nulls should be omitted
expect(result?.differences.tags).toBeUndefined();
expect(result?.differences.categories).toBeUndefined();
expect(result?.differences.excerpt).toBeUndefined();
expect(result?.differences.author).toBeUndefined();
expect(result?.differences.language).toBeUndefined();
});
it('should return hasDifferences=false when metadata matches', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '["tag1"]',
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["tag1"]
categories: ["cat1"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(false);
});
});
describe('scanAllPublishedPosts', () => {
it('should scan all published posts and return differences', async () => {
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
{
id: 'post-2',
title: 'Post 2',
slug: 'post-2',
file_path: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
],
};
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return {
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
};
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [] };
}
return { rows: [] };
});
// Queue the posts for sequential .get() calls in comparePostMetadata
mockPostsGetQueue = [
{
id: 'post-1',
projectId: 'test-project',
title: 'Post 1',
slug: 'post-1',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
{
id: 'post-2',
projectId: 'test-project',
title: 'Post 2',
slug: 'post-2',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
];
// Post 1 has tag difference
mockFileData.set('/mock/userData/posts/2024/01/post-1.md', `---
id: post-1
projectId: test-project
title: "Post 1"
slug: post-1
status: published
tags: ["old-tag"]
categories: ["cat1"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content`);
// Post 2 matches
mockFileData.set('/mock/userData/posts/2024/01/post-2.md', `---
id: post-2
projectId: test-project
title: "Post 2"
slug: post-2
status: published
tags: ["tag1"]
categories: ["cat1"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content`);
const result = await engine.scanAllPublishedPosts((current, total) => {});
expect(result.totalScanned).toBe(2);
expect(result.postsWithDifferences).toBe(1);
expect(result.differences.length).toBe(1);
expect(result.differences[0].postId).toBe('post-1');
expect(result.orphanFiles).toEqual([]);
});
it('should detect orphan files when postsBaseDir is provided', async () => {
const { readdir } = await import('fs/promises');
const mockReaddir = vi.mocked(readdir);
// Mock published posts query - one post in DB
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
excerpt: null,
author: null,
},
],
});
// Mock the all-posts query
mockLocalClient.execute.mockResolvedValueOnce({
rows: [{ file_path: '/mock/posts/2024/01/post-1.md' }],
});
// Queue the post for comparePostMetadata
mockPostsGetQueue = [
{
id: 'post-1',
projectId: 'test-project',
title: 'Post 1',
slug: 'post-1',
status: 'published',
filePath: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
},
];
// File matches DB (no differences)
mockFileData.set('/mock/posts/2024/01/post-1.md', `---
id: post-1
title: "Post 1"
slug: post-1
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content`);
// Orphan file exists on disk
mockFileData.set('/mock/posts/2024/01/orphan-post.md', `---
id: orphan-1
title: "Orphan Post"
slug: orphan-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content`);
// Mock readdir to return directory structure
mockReaddir
.mockResolvedValueOnce([
{ name: '2024', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: '01', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: 'post-1.md', isDirectory: () => false, isFile: () => true } as any,
{ name: 'orphan-post.md', isDirectory: () => false, isFile: () => true } as any,
] as any);
const result = await engine.scanAllPublishedPosts(
(current, total) => {},
'/mock/posts',
);
expect(result.orphanFiles).toHaveLength(1);
expect(result.orphanFiles[0].slug).toBe('orphan-post');
expect(result.orphanFiles[0].title).toBe('Orphan Post');
expect(result.orphanFiles[0].id).toBe('orphan-1');
expect(result.orphanFiles[0].filePath).toBe('/mock/posts/2024/01/orphan-post.md');
});
it('should return empty orphanFiles when no postsBaseDir is provided', async () => {
// Mock published posts query - empty
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
// Mock the all-posts query
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
const result = await engine.scanAllPublishedPosts((current, total) => {});
expect(result.orphanFiles).toEqual([]);
});
it('should not flag draft posts as orphans', async () => {
const { readdir } = await import('fs/promises');
const mockReaddir = vi.mocked(readdir);
// No published posts
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
// All posts query returns a draft that has a file
mockLocalClient.execute.mockResolvedValueOnce({
rows: [{ file_path: '/mock/posts/2024/01/draft-post.md' }],
});
// Mock readdir to find the draft file
mockReaddir
.mockResolvedValueOnce([
{ name: '2024', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: '01', isDirectory: () => true, isFile: () => false } as any,
] as any)
.mockResolvedValueOnce([
{ name: 'draft-post.md', isDirectory: () => false, isFile: () => true } as any,
] as any);
const result = await engine.scanAllPublishedPosts(
(current, total) => {},
'/mock/posts',
);
// Draft post file should NOT be flagged as orphan since it's in the DB
expect(result.orphanFiles).toEqual([]);
});
it('should include published translation metadata differences in the scan', async () => {
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'translation-1',
translation_for: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
file_path: '/mock/posts/2024/01/post-1.de.md',
},
],
};
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [{ file_path: '/mock/posts/2024/01/post-1.de.md' }] };
}
return { rows: [] };
});
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
if (selectCall === 2) {
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
content: null,
status: 'published',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
filePath: '/mock/posts/2024/01/post-1.de.md',
});
}
return Promise.resolve({
id: 'post-1',
projectId: 'test-project',
title: 'Hello World',
slug: 'post-1',
status: 'published',
filePath: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
});
}),
});
return chain;
});
mockFileData.set('/mock/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "File excerpt"
---
Translated content`);
const result = await engine.scanAllPublishedPosts((current, total) => {}, '/mock/posts');
expect(result.totalScanned).toBe(1);
expect(result.postsWithDifferences).toBe(1);
expect(result.differences).toHaveLength(1);
expect(result.differences[0].postId).toBe('translation-1');
expect(result.differences[0].differences.excerpt).toEqual({ dbValue: 'DB excerpt', fileValue: 'File excerpt' });
expect(result.orphanFiles).toEqual([]);
});
});
describe('importOrphanFiles', () => {
it('should import orphan files and report success/failed counts', async () => {
mockImportOrphanFile
.mockResolvedValueOnce({ id: 'id-1', title: 'First' })
.mockResolvedValueOnce(null) // parse failure
.mockResolvedValueOnce({ id: 'id-3', title: 'Third' });
const result = await engine.importOrphanFiles([
'/posts/2024/01/first.md',
'/posts/2024/01/bad.md',
'/posts/2024/01/third.md',
]);
expect(result.success).toBe(2);
expect(result.failed).toBe(1);
expect(mockImportOrphanFile).toHaveBeenCalledTimes(3);
});
it('should handle exceptions from importOrphanFile gracefully', async () => {
mockImportOrphanFile
.mockRejectedValueOnce(new Error('DB constraint error'));
const result = await engine.importOrphanFiles(['/posts/2024/01/crash.md']);
expect(result.success).toBe(0);
expect(result.failed).toBe(1);
});
it('should report progress during import', async () => {
mockImportOrphanFile.mockResolvedValue({ id: 'id', title: 'Post' });
const progressCalls: [number, number, string][] = [];
await engine.importOrphanFiles(
['/a.md', '/b.md', '/c.md', '/d.md', '/e.md'],
(current, total, message) => progressCalls.push([current, total, message]),
);
// Progress should be reported at i=4 (5th item, i+1=5 is divisible by 5)
expect(progressCalls.length).toBe(1);
expect(progressCalls[0][0]).toBe(5);
expect(progressCalls[0][1]).toBe(5);
});
it('should import orphan translation files into the translations table path', async () => {
mockFileData.set('/posts/2024/01/post.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "Translated excerpt"
---
Translated content`);
const result = await engine.importOrphanFiles(['/posts/2024/01/post.de.md']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockImportOrphanTranslationFile).toHaveBeenCalledWith('/posts/2024/01/post.de.md');
expect(mockImportOrphanFile).not.toHaveBeenCalled();
});
});
describe('groupDifferencesByField', () => {
it('should group differences by field type', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'post-1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
tags: { dbValue: ['new-tag'], fileValue: ['old-tag'] },
},
},
{
postId: 'post-2',
title: 'Post 2',
slug: 'post-2',
hasDifferences: true,
differences: {
tags: { dbValue: ['tag1'], fileValue: ['tag2'] },
categories: { dbValue: ['cat1'], fileValue: ['cat2'] },
},
},
{
postId: 'post-3',
title: 'Post 3',
slug: 'post-3',
hasDifferences: true,
differences: {
categories: { dbValue: ['catA'], fileValue: ['catB'] },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
expect(groups).toHaveLength(2);
const tagsGroup = groups.find(g => g.field === 'tags');
expect(tagsGroup).toBeDefined();
expect(tagsGroup?.posts).toHaveLength(2);
const categoriesGroup = groups.find(g => g.field === 'categories');
expect(categoriesGroup).toBeDefined();
expect(categoriesGroup?.posts).toHaveLength(2);
});
});
describe('syncDbToFile', () => {
it('should sync database metadata to file for given posts', async () => {
const postIds = ['post-1', 'post-2'];
// This will call syncPublishedPostFile for each post
await engine.syncDbToFile(postIds);
// PostEngine.syncPublishedPostFile should have been called twice
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(2);
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-1');
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('post-2');
});
it('should report progress on first and final items based on cadence', async () => {
const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`);
const onProgress = vi.fn();
await engine.syncDbToFile(postIds, onProgress);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...');
expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...');
});
it('should keep processing and count failures when sync throws or returns false', async () => {
mockSyncPublishedPostFile
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('sync failure'));
mockSyncPublishedPostTranslationFile
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']);
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3);
});
it('should fall back to syncing published translation files when the post file sync does not apply', async () => {
mockSyncPublishedPostFile.mockResolvedValueOnce(false);
mockSyncPublishedPostTranslationFile.mockResolvedValueOnce(true);
const result = await engine.syncDbToFile(['translation-1']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('translation-1');
expect(mockSyncPublishedPostTranslationFile).toHaveBeenCalledWith('translation-1');
});
});
describe('syncFileToDb', () => {
it('should sync file metadata to database for given posts', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '["db-tag"]',
categories: '["db-cat"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["file-tag"]
categories: ["file-cat"]
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'tags');
// Verify the database update was called
expect(mockLocalDb.update).toHaveBeenCalled();
});
it('should sync language field from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'en',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
language: fr
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'language');
expect(mockLocalDb.update).toHaveBeenCalled();
// Verify the set call includes language
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.language).toBe('fr');
});
it('should report progress on first and final items based on cadence', async () => {
const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`);
mockPostsGetQueue = postIds.map((postId) => ({
id: postId,
projectId: 'test-project',
title: `Post ${postId}`,
slug: postId,
status: 'published',
filePath: `/mock/userData/posts/2024/01/${postId}.md`,
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
}));
for (const postId of postIds) {
mockFileData.set(`/mock/userData/posts/2024/01/${postId}.md`, `---\nid: ${postId}\nprojectId: test-project\ntitle: "${postId}"\nslug: ${postId}\nstatus: published\ntags: []\ncategories: []\n---\nContent`);
}
const onProgress = vi.fn();
await engine.syncFileToDb(postIds, undefined, onProgress);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 9, 'Synced 1 of 11 posts...');
expect(onProgress).toHaveBeenNthCalledWith(2, 100, 'Synced 11 of 11 posts...');
});
it('should continue after missing file path and file read failures', async () => {
const postIds = ['post-1', 'post-2', 'post-3'];
mockPostsGetQueue = [
{
id: 'post-1',
projectId: 'test-project',
title: 'Post 1',
slug: 'post-1',
status: 'published',
filePath: null,
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
},
{
id: 'post-2',
projectId: 'test-project',
title: 'Post 2',
slug: 'post-2',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-2.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
},
{
id: 'post-3',
projectId: 'test-project',
title: 'Post 3',
slug: 'post-3',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-3.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
},
];
mockFileData.set('/mock/userData/posts/2024/01/post-3.md', `---\nid: post-3\nprojectId: test-project\ntitle: "Post 3"\nslug: post-3\nstatus: published\ntags: ["from-file"]\ncategories: []\n---\nContent`);
const result = await engine.syncFileToDb(postIds, 'tags');
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
});
it('should sync published translation metadata from file to database', async () => {
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-1.de.md',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
});
}),
});
return chain;
});
mockFileData.set('/mock/userData/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Datei"
excerpt: "File excerpt"
---
Translated content`);
await engine.syncFileToDb(['translation-1'], 'title');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.title).toBe('Hallo Datei');
});
});
// ── Media diff tests ──
describe('compareMediaMetadata', () => {
let mediaEngine: MetadataDiffEngine;
const mockReadSidecarFile = vi.fn();
beforeEach(() => {
mediaEngine = new MetadataDiffEngine(
undefined,
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
);
mediaEngine.setProjectContext('test-project');
});
it('should return null when media not found in DB', async () => {
// (mock DB returns undefined for .get())
const result = await mediaEngine.compareMediaMetadata('nonexistent');
expect(result).toBeNull();
});
it('should detect title difference between DB and sidecar', async () => {
const dbMedia = {
id: 'media-1',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'DB Title',
alt: 'alt text',
caption: '',
author: '',
tags: '[]',
};
mockPosts.set('media-1', dbMedia);
// The select chain's .get() will return this via mockPosts
// Override: media uses same mock DB, so route DB response:
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-1',
originalName: 'photo.jpg',
title: 'File Title',
alt: 'alt text',
caption: '',
author: '',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.title).toEqual({ dbValue: 'DB Title', fileValue: 'File Title' });
});
it('should detect tag differences between DB and sidecar', async () => {
const dbMedia = {
id: 'media-2',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: '["tag1","tag2"]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-2',
originalName: 'photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: ['tag1'],
});
const result = await mediaEngine.compareMediaMetadata('media-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.tags).toEqual({ dbValue: ['tag1', 'tag2'], fileValue: ['tag1'] });
});
it('should return hasDifferences=false when metadata matches', async () => {
const dbMedia = {
id: 'media-3',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Same',
alt: 'Same alt',
caption: '',
author: '',
tags: '["t1"]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce({
title: 'Same',
alt: 'Same alt',
caption: '',
author: '',
tags: ['t1'],
});
const result = await mediaEngine.compareMediaMetadata('media-3');
expect(result?.hasDifferences).toBe(false);
});
it('should flag when sidecar file is missing', async () => {
const dbMedia = {
id: 'media-4',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: '',
alt: '',
caption: '',
author: '',
tags: '[]',
};
mockPostsGetQueue = [dbMedia];
mockReadSidecarFile.mockResolvedValueOnce(null);
const result = await mediaEngine.compareMediaMetadata('media-4');
expect(result?.hasDifferences).toBe(true);
});
});
// ── Script diff tests ──
describe('compareScriptMetadata', () => {
let scriptEngine: MetadataDiffEngine;
const mockReadScriptFileWithMetadata = vi.fn();
beforeEach(() => {
scriptEngine = new MetadataDiffEngine(
undefined,
undefined,
{ readScriptFileWithMetadata: mockReadScriptFileWithMetadata, getScript: vi.fn(), updateScript: vi.fn() } as any,
);
scriptEngine.setProjectContext('test-project');
});
it('should skip draft scripts', async () => {
mockPostsGetQueue = [{
id: 'script-1',
projectId: 'test-project',
title: 'Draft Script',
slug: 'draft-script',
status: 'draft',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/scripts/draft.py',
}];
const result = await scriptEngine.compareScriptMetadata('script-1');
expect(result).toBeNull();
});
it('should detect title difference between DB and file', async () => {
mockPostsGetQueue = [{
id: 'script-2',
projectId: 'test-project',
title: 'DB Title',
slug: 'my-script',
status: 'published',
kind: 'macro',
entrypoint: 'render',
enabled: true,
version: 3,
filePath: '/mock/scripts/my-script.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'File Title', kind: 'macro', entrypoint: 'render', enabled: true, version: 3 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.title).toEqual({ dbValue: 'DB Title', fileValue: 'File Title' });
});
it('should detect version difference', async () => {
mockPostsGetQueue = [{
id: 'script-3',
projectId: 'test-project',
title: 'Script',
slug: 'script',
status: 'published',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 5,
filePath: '/mock/scripts/script.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Script', kind: 'utility', entrypoint: 'render', enabled: true, version: 3 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-3');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.version).toEqual({ dbValue: 5, fileValue: 3 });
});
it('should return hasDifferences=false when metadata matches', async () => {
mockPostsGetQueue = [{
id: 'script-4',
projectId: 'test-project',
title: 'Same',
slug: 'same',
status: 'published',
kind: 'utility',
entrypoint: 'render',
enabled: true,
version: 1,
filePath: '/mock/scripts/same.py',
}];
mockReadScriptFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Same', kind: 'utility', entrypoint: 'render', enabled: true, version: 1 },
body: '',
});
const result = await scriptEngine.compareScriptMetadata('script-4');
expect(result?.hasDifferences).toBe(false);
});
});
// ── Template diff tests ──
describe('compareTemplateMetadata', () => {
let templateEngine: MetadataDiffEngine;
const mockReadTemplateFileWithMetadata = vi.fn();
beforeEach(() => {
templateEngine = new MetadataDiffEngine(
undefined,
undefined,
undefined,
{ readTemplateFileWithMetadata: mockReadTemplateFileWithMetadata, getTemplate: vi.fn(), updateTemplate: vi.fn() } as any,
);
templateEngine.setProjectContext('test-project');
});
it('should skip draft templates', async () => {
mockPostsGetQueue = [{
id: 'tpl-1',
projectId: 'test-project',
title: 'Draft Template',
slug: 'draft-tpl',
status: 'draft',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/draft.liquid',
}];
const result = await templateEngine.compareTemplateMetadata('tpl-1');
expect(result).toBeNull();
});
it('should detect kind difference between DB and file', async () => {
mockPostsGetQueue = [{
id: 'tpl-2',
projectId: 'test-project',
title: 'Template',
slug: 'template',
status: 'published',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/template.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Template', kind: 'list', enabled: true, version: 1 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-2');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.kind).toEqual({ dbValue: 'post', fileValue: 'list' });
});
it('should detect enabled difference', async () => {
mockPostsGetQueue = [{
id: 'tpl-3',
projectId: 'test-project',
title: 'Tpl',
slug: 'tpl',
status: 'published',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/templates/tpl.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Tpl', kind: 'post', enabled: false, version: 1 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-3');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.enabled).toEqual({ dbValue: true, fileValue: false });
});
it('should return hasDifferences=false when metadata matches', async () => {
mockPostsGetQueue = [{
id: 'tpl-4',
projectId: 'test-project',
title: 'Same',
slug: 'same',
status: 'published',
kind: 'partial',
enabled: true,
version: 2,
filePath: '/mock/templates/same.liquid',
}];
mockReadTemplateFileWithMetadata.mockResolvedValueOnce({
metadata: { title: 'Same', kind: 'partial', enabled: true, version: 2 },
body: '',
});
const result = await templateEngine.compareTemplateMetadata('tpl-4');
expect(result?.hasDifferences).toBe(false);
});
});
// ── getTableStats with expanded counts ──
describe('getTableStats (expanded)', () => {
it('should include script and template counts', async () => {
mockLocalClient.execute
.mockResolvedValueOnce({ rows: [{ count: 10 }] }) // total posts
.mockResolvedValueOnce({ rows: [{ count: 8 }] }) // published posts
.mockResolvedValueOnce({ rows: [{ count: 2 }] }) // draft posts
.mockResolvedValueOnce({ rows: [{ count: 50 }] }) // total media
.mockResolvedValueOnce({ rows: [{ count: 5 }] }) // total scripts
.mockResolvedValueOnce({ rows: [{ count: 4 }] }) // published scripts
.mockResolvedValueOnce({ rows: [{ count: 7 }] }) // total templates
.mockResolvedValueOnce({ rows: [{ count: 6 }] }); // published templates
const stats = await engine.getTableStats();
expect(stats).toEqual({
totalPosts: 10,
publishedPosts: 8,
draftPosts: 2,
totalMedia: 50,
totalScripts: 5,
publishedScripts: 4,
totalTemplates: 7,
publishedTemplates: 6,
});
});
});
describe('comparePostMetadata new fields', () => {
it('should detect doNotTranslate differences between DB and file', async () => {
const dbPost = {
id: 'post-dnt',
projectId: 'test-project',
title: 'DNT Post',
slug: 'dnt-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/dnt-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: true,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-dnt', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/dnt-post.md', `---
id: post-dnt
projectId: test-project
title: "DNT Post"
slug: dnt-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-dnt');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.doNotTranslate).toBeDefined();
expect(result?.differences.doNotTranslate?.dbValue).toBe(true);
expect(result?.differences.doNotTranslate?.fileValue).toBe(false);
});
it('should detect templateSlug differences between DB and file', async () => {
const dbPost = {
id: 'post-tpl',
projectId: 'test-project',
title: 'Template Post',
slug: 'template-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/template-post.md',
tags: '[]',
categories: '[]',
templateSlug: 'blog-default',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-tpl', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/template-post.md', `---
id: post-tpl
projectId: test-project
title: "Template Post"
slug: template-post
status: published
templateSlug: old-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-tpl');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.templateSlug).toBeDefined();
expect(result?.differences.templateSlug?.dbValue).toBe('blog-default');
expect(result?.differences.templateSlug?.fileValue).toBe('old-template');
});
it('should detect status differences between DB and file', async () => {
const dbPost = {
id: 'post-status',
projectId: 'test-project',
title: 'Archived Post',
slug: 'archived-post',
status: 'archived',
filePath: '/mock/userData/posts/2024/01/archived-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-status', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/archived-post.md', `---
id: post-status
projectId: test-project
title: "Archived Post"
slug: archived-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-status');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.status).toBeDefined();
expect(result?.differences.status?.dbValue).toBe('archived');
expect(result?.differences.status?.fileValue).toBe('published');
});
it('should show no differences when new fields match', async () => {
const dbPost = {
id: 'post-match',
projectId: 'test-project',
title: 'Matching Post',
slug: 'matching-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/matching-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: false,
templateSlug: null,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-match', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/matching-post.md', `---
id: post-match
projectId: test-project
title: "Matching Post"
slug: matching-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-match');
expect(result?.hasDifferences).toBe(false);
});
});
describe('syncFileToDb new fields', () => {
it('should sync doNotTranslate from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
doNotTranslate: true
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'doNotTranslate');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.doNotTranslate).toBe(true);
});
it('should sync templateSlug from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
templateSlug: my-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'templateSlug');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.templateSlug).toBe('my-template');
});
it('should sync status from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: archived
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'status');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.status).toBe('archived');
});
});
describe('groupDifferencesByField new fields', () => {
it('should include new fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
doNotTranslate: { dbValue: true, fileValue: false },
templateSlug: { dbValue: 'tpl-a', fileValue: 'tpl-b' },
status: { dbValue: 'published', fileValue: 'archived' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('doNotTranslate');
expect(fieldNames).toContain('templateSlug');
expect(fieldNames).toContain('status');
const dntGroup = groups.find(g => g.field === 'doNotTranslate');
expect(dntGroup?.label).toBe('Do Not Translate');
const tplGroup = groups.find(g => g.field === 'templateSlug');
expect(tplGroup?.label).toBe('Template');
const statusGroup = groups.find(g => g.field === 'status');
expect(statusGroup?.label).toBe('Status');
});
it('should include timestamp fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
createdAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-02-15T00:00:00.000Z' },
updatedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-03-01T00:00:00.000Z' },
publishedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('createdAt');
expect(fieldNames).toContain('updatedAt');
expect(fieldNames).toContain('publishedAt');
expect(groups.find(g => g.field === 'createdAt')?.label).toBe('Created At');
expect(groups.find(g => g.field === 'updatedAt')?.label).toBe('Updated At');
expect(groups.find(g => g.field === 'publishedAt')?.label).toBe('Published At');
});
});
describe('comparePostMetadata timestamp diffs', () => {
it('should detect createdAt differences between DB and file', async () => {
const dbPost = {
id: 'post-ts',
projectId: 'test-project',
title: 'Timestamp Post',
slug: 'timestamp-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/timestamp-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-ts', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/timestamp-post.md', `---
id: post-ts
projectId: test-project
title: "Timestamp Post"
slug: timestamp-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-ts');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.createdAt).toBeDefined();
expect(result?.differences.createdAt?.fileValue).toContain('2024-06-01');
});
it('should detect publishedAt differences when DB has it but file does not', async () => {
const dbPost = {
id: 'post-pa',
projectId: 'test-project',
title: 'Published At Post',
slug: 'published-at-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-at-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-pa', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-at-post.md', `---
id: post-pa
projectId: test-project
title: "Published At Post"
slug: published-at-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-pa');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.publishedAt).toBeDefined();
});
it('should show no timestamp differences when they match at second precision', async () => {
const dbPost = {
id: 'post-eq',
projectId: 'test-project',
title: 'Equal Post',
slug: 'equal-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/equal-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T12:30:00.000Z'),
updatedAt: new Date('2024-01-15T12:30:00.000Z'),
publishedAt: new Date('2024-01-15T12:30:00.000Z'),
};
mockPosts.set('post-eq', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/equal-post.md', `---
id: post-eq
projectId: test-project
title: "Equal Post"
slug: equal-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T12:30:00.000Z
updatedAt: 2024-01-15T12:30:00.000Z
publishedAt: 2024-01-15T12:30:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-eq');
expect(result?.hasDifferences).toBe(false);
expect(result?.differences.createdAt).toBeUndefined();
expect(result?.differences.updatedAt).toBeUndefined();
expect(result?.differences.publishedAt).toBeUndefined();
});
});
describe('syncFileToDb timestamp fields', () => {
it('should sync createdAt from file to database using file value', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'createdAt');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.createdAt).toBeInstanceOf(Date);
expect(setCall.createdAt.toISOString()).toContain('2024-06-01');
// Should NOT auto-set updatedAt when syncing a timestamp field
expect(setCall.updatedAt).toBeUndefined();
});
it('should auto-set updatedAt when syncing a non-timestamp field', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["new-tag"]
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'tags');
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
// Should auto-set updatedAt for non-timestamp field syncs
expect(setCall.updatedAt).toBeInstanceOf(Date);
});
});
describe('compareMediaMetadata language diff', () => {
let mediaEngine: MetadataDiffEngine;
const mockReadSidecarFile = vi.fn();
beforeEach(() => {
mediaEngine = new MetadataDiffEngine(
undefined,
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
);
mediaEngine.setProjectContext('test-project');
});
it('should detect media language differences', async () => {
mockPostsGetQueue = [{
id: 'media-1',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'en',
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-1',
originalName: 'photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'fr',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-1');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('en');
expect(result?.differences.language?.fileValue).toBe('fr');
});
it('should not flag language when both are empty', async () => {
mockPostsGetQueue = [{
id: 'media-2',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: '',
caption: '',
author: '',
language: null,
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
title: 'Photo',
alt: '',
caption: '',
author: '',
language: '',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-2');
expect(result?.differences.language).toBeUndefined();
});
});
});