From b410736a67b9a3315582e75acc17a4bf040a86b4 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 19 Feb 2026 09:40:12 +0100 Subject: [PATCH] fix: make sitemap work properly --- src/main/engine/MetaEngine.ts | 13 + src/main/ipc/handlers.ts | 81 +++-- src/main/shared/electronApi.ts | 5 +- .../components/SettingsView/SettingsView.tsx | 25 +- tests/engine/MetaEngine.test.ts | 24 ++ tests/ipc/handlers.test.ts | 303 ++++++++++++++---- .../renderer/components/SettingsView.test.tsx | 19 +- 7 files changed, 389 insertions(+), 81 deletions(-) diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 21e953e..623aed3 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -18,6 +18,7 @@ export interface ProjectMetadata { name: string; description?: string; dataPath?: string; // Custom path for project data + publicUrl?: string; // Public base URL for the published blog (e.g., https://example.com) mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) @@ -48,10 +49,21 @@ function sanitizeMaxPostsPerPage(value: unknown): number | undefined { return rounded; } +function sanitizePublicUrl(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const trimmed = String(value).trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); + const publicUrl = sanitizePublicUrl(metadata.publicUrl); return { ...metadata, + publicUrl, maxPostsPerPage, }; } @@ -173,6 +185,7 @@ export class MetaEngine extends EventEmitter { name: normalizedUpdates.name || '', description: normalizedUpdates.description, dataPath: normalizedUpdates.dataPath, + publicUrl: normalizedUpdates.publicUrl, mainLanguage: normalizedUpdates.mainLanguage, defaultAuthor: normalizedUpdates.defaultAuthor, maxPostsPerPage: normalizedUpdates.maxPostsPerPage, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 2e31d11..5ce95c2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -111,6 +111,25 @@ function buildSitemapUrl( ].join('\n'); } +function resolvePublicBaseUrl(publicUrl?: string): string | null { + const trimmed = (publicUrl || '').trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`; + } catch { + return null; + } +} + export function registerIpcHandlers(): void { // ============ Git Handlers ============ @@ -749,6 +768,7 @@ export function registerIpcHandlers(): void { return { name: metadata.name || undefined, description: metadata.description || undefined, + publicUrl: metadata.publicUrl || undefined, mainLanguage: metadata.mainLanguage || undefined, }; } catch { @@ -847,7 +867,7 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { const engine = getMetaEngine(); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); @@ -1306,12 +1326,33 @@ export function registerIpcHandlers(): void { safeHandle('blog:generateSitemap', async () => { const projectEngine = getProjectEngine(); + const postEngine = getPostEngine(); + const metaEngine = getMetaEngine(); const project = await projectEngine.getActiveProject(); if (!project) { throw new Error('No active project'); } const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + postEngine.setProjectContext(project.id, dataDir); + metaEngine.setProjectContext(project.id, dataDir); + + if (!metaEngine.isInitialized()) { + await metaEngine.syncOnStartup(); + } + + const metadata = await metaEngine.getProjectMetadata(); + const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl); + if (!baseUrl) { + await dialog.showMessageBox({ + type: 'warning', + title: 'Public URL Required', + message: 'Sitemap generation requires a public URL.', + detail: 'Set Project → Public URL in Settings before generating a sitemap.', + }); + throw new Error('Project public URL is not configured'); + } + const taskId = `sitemap-generate-${Date.now()}`; return taskManager.runTask({ @@ -1320,23 +1361,28 @@ export function registerIpcHandlers(): void { execute: async (onProgress) => { onProgress(0, 'Loading posts...'); - const db = getDatabase().getLocal(); - const { posts: postsTable } = await import('../database/schema'); - const { eq: eqOp, desc: descOp } = await import('drizzle-orm'); + const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' }); + const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' }); - const dbPosts = await db - .select() - .from(postsTable) - .where(eqOp(postsTable.projectId, project.id)) - .orderBy(descOp(postsTable.createdAt)) - .all(); + const draftPublishedSnapshots = await Promise.all( + draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)), + ); - // Only include published posts (not drafts or archived) in sitemap - const publishedPosts = dbPosts.filter(p => p.status === 'published'); + const publishedPostById = new Map(); + for (const post of publishedCandidates) { + publishedPostById.set(post.id, post); + } + for (const snapshot of draftPublishedSnapshots) { + if (snapshot) { + publishedPostById.set(snapshot.id, snapshot); + } + } + + const publishedPosts = Array.from(publishedPostById.values()) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); onProgress(10, `Found ${publishedPosts.length} published posts`); - const baseUrl = 'http://127.0.0.1:4123'; const now = new Date().toISOString(); // Collect all unique tags, categories, and year/month/day archives @@ -1349,9 +1395,8 @@ export function registerIpcHandlers(): void { const postUrls: Array<{ loc: string; lastmod: string }> = []; for (const post of publishedPosts) { - // Parse tags and categories - const tags: string[] = JSON.parse(post.tags || '[]'); - const categories: string[] = JSON.parse(post.categories || '[]'); + const tags = post.tags || []; + const categories = post.categories || []; for (const tag of tags) allTags.add(tag); for (const cat of categories) allCategories.add(cat); @@ -1360,9 +1405,7 @@ export function registerIpcHandlers(): void { const createdAt = resolvePostCreatedAt(post); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const postUrl = `${baseUrl}${canonicalPath}`; - const updatedAt = post.updatedAt instanceof Date - ? post.updatedAt - : new Date(post.updatedAt as unknown as Date | string | number); + const updatedAt = post.updatedAt; postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() }); // Track archives diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index fc1564d..ab5110f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -38,6 +38,7 @@ export interface ProjectMetadata { name: string; description?: string; dataPath?: string; + publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; @@ -510,7 +511,7 @@ export interface ElectronAPI { showItemInFolder: (itemPath: string) => Promise; selectFolder: (title?: string) => Promise; getDefaultProjectPath: (projectId: string) => Promise; - readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; + readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; setPreviewPostTarget: (postId: string | null) => Promise; triggerMenuAction: (action: string) => Promise; }; @@ -524,7 +525,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 03f52a6..0ecbc3c 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -107,6 +107,7 @@ export const SettingsView: React.FC = () => { const [projectName, setProjectName] = useState(''); const [projectDescription, setProjectDescription] = useState(''); const [projectDataPath, setProjectDataPath] = useState(''); + const [projectPublicUrl, setProjectPublicUrl] = useState(''); const [defaultProjectPath, setDefaultProjectPath] = useState(''); const [projectMainLanguage, setProjectMainLanguage] = useState('en'); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); @@ -145,8 +146,13 @@ export const SettingsView: React.FC = () => { setDefaultProjectPath(path); }); - // Load project metadata (includes mainLanguage and defaultAuthor) + // Load project metadata (includes public URL, language, and default author) window.electronAPI?.meta.getProjectMetadata().then(metadata => { + if (metadata?.publicUrl) { + setProjectPublicUrl(metadata.publicUrl); + } else { + setProjectPublicUrl(''); + } if (metadata?.mainLanguage) { setProjectMainLanguage(metadata.mainLanguage); } @@ -256,6 +262,7 @@ export const SettingsView: React.FC = () => { name: projectName.trim() || activeProject.name, description: projectDescription.trim(), dataPath: projectDataPath.trim() || undefined, + publicUrl: projectPublicUrl.trim() || undefined, mainLanguage: projectMainLanguage, defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), @@ -280,7 +287,7 @@ export const SettingsView: React.FC = () => { }; // Keywords for each section for search filtering - const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; + const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; @@ -346,6 +353,20 @@ export const SettingsView: React.FC = () => { + + setProjectPublicUrl(e.target.value)} + /> + + { expect(metadata?.maxPostsPerPage).toBe(42); }); + it('should set and get publicUrl in project metadata', async () => { + await metaEngine.setProjectMetadata({ + name: 'My Blog', + publicUrl: 'https://example.com/blog', + }); + + const metadata = await metaEngine.getProjectMetadata(); + expect(metadata?.publicUrl).toBe('https://example.com/blog'); + }); + + it('should persist publicUrl to filesystem', async () => { + await metaEngine.setProjectMetadata({ + name: 'Test Project', + publicUrl: 'https://example.com', + }); + + const metaDir = metaEngine.getMetaDir(); + const projectPath = normalizePath(`${metaDir}/project.json`); + + const content = mockFiles.get(projectPath); + const parsed = JSON.parse(content!); + expect(parsed.publicUrl).toBe('https://example.com'); + }); + it('should sanitize invalid maxPostsPerPage values from filesystem', async () => { const metaDir = metaEngine.getMetaDir(); const projectPath = normalizePath(`${metaDir}/project.json`); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index ff77a5d..039a250 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -30,6 +30,7 @@ vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn(), + showMessageBox: vi.fn(), }, shell: { openPath: vi.fn(), @@ -52,6 +53,7 @@ const mockPostEngine = { publishPost: vi.fn(), discardChanges: vi.fn(), hasPublishedVersion: vi.fn(), + getPublishedVersion: vi.fn(), isSlugAvailable: vi.fn(), generateUniqueSlug: vi.fn(), rebuildDatabaseFromFiles: vi.fn(), @@ -1448,9 +1450,13 @@ describe('IPC Handlers', () => { }); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com', + }); - // Mock database query to return posts - const mockDbPosts = [ + // Mock post engine to return published posts and drafts + const mockPublishedPosts = [ { id: 'post-1', projectId: 'test-project', @@ -1458,8 +1464,8 @@ describe('IPC Handlers', () => { status: 'published', createdAt: new Date('2024-01-15T10:00:00Z'), updatedAt: new Date('2024-01-20T15:00:00Z'), - tags: '["tag1","tag2"]', - categories: '["category1"]', + tags: ['tag1', 'tag2'], + categories: ['category1'], }, { id: 'post-2', @@ -1468,9 +1474,12 @@ describe('IPC Handlers', () => { status: 'published', createdAt: new Date('2024-02-10T12:00:00Z'), updatedAt: new Date('2024-02-12T09:00:00Z'), - tags: '["tag2","tag3"]', - categories: '["category2"]', + tags: ['tag2', 'tag3'], + categories: ['category2'], }, + ]; + + const mockDraftPosts = [ { id: 'post-3', projectId: 'test-project', @@ -1478,24 +1487,21 @@ describe('IPC Handlers', () => { status: 'draft', createdAt: new Date('2024-03-01T08:00:00Z'), updatedAt: new Date('2024-03-01T08:00:00Z'), - tags: '[]', - categories: '[]', + tags: [], + categories: [], }, ]; - const mockSelect = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(() => ({ - all: vi.fn().mockResolvedValue(mockDbPosts), - })), - })), - })), - }; - - mockDatabase.getLocal.mockReturnValue({ - select: vi.fn(() => mockSelect), + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return mockDraftPosts; + } + return []; }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); // Mock fs.writeFile const { writeFile, mkdir } = await import('fs/promises'); @@ -1553,8 +1559,12 @@ describe('IPC Handlers', () => { }); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com', + }); - const mockDbPosts = [ + const mockPublishedPosts = [ { id: 'post-1', projectId: 'test-project', @@ -1562,9 +1572,12 @@ describe('IPC Handlers', () => { status: 'published', createdAt: new Date('2024-01-15T10:00:00Z'), updatedAt: new Date('2024-01-20T15:00:00Z'), - tags: '[]', - categories: '[]', + tags: [], + categories: [], }, + ]; + + const mockDraftPosts = [ { id: 'post-2', projectId: 'test-project', @@ -1572,9 +1585,12 @@ describe('IPC Handlers', () => { status: 'draft', createdAt: new Date('2024-02-10T12:00:00Z'), updatedAt: new Date('2024-02-12T09:00:00Z'), - tags: '[]', - categories: '[]', + tags: [], + categories: [], }, + ]; + + const mockArchivedPosts = [ { id: 'post-3', projectId: 'test-project', @@ -1582,24 +1598,24 @@ describe('IPC Handlers', () => { status: 'archived', createdAt: new Date('2024-03-01T08:00:00Z'), updatedAt: new Date('2024-03-01T08:00:00Z'), - tags: '[]', - categories: '[]', + tags: [], + categories: [], }, ]; - const mockSelect = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(() => ({ - all: vi.fn().mockResolvedValue(mockDbPosts), - })), - })), - })), - }; - - mockDatabase.getLocal.mockReturnValue({ - select: vi.fn(() => mockSelect), + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return mockDraftPosts; + } + if (filter.status === 'archived') { + return mockArchivedPosts; + } + return []; }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); const { writeFile, mkdir } = await import('fs/promises'); vi.mocked(mkdir).mockResolvedValue(undefined); @@ -1624,6 +1640,105 @@ describe('IPC Handlers', () => { expect(sitemapXml).not.toContain('archived-post'); }); + it('should include published snapshot for drafts with a former published version', async () => { + const mockProject = createMockProject({ + id: 'test-project', + dataPath: '/mock/data', + }); + mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); + mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com', + }); + + const publishedPost = { + id: 'post-published', + projectId: 'test-project', + slug: 'published-post', + status: 'published', + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-20T15:00:00Z'), + tags: [], + categories: [], + }; + + const neverPublishedDraft = { + id: 'post-draft-new', + projectId: 'test-project', + slug: 'draft-no-published-version', + status: 'draft', + createdAt: new Date('2024-02-10T12:00:00Z'), + updatedAt: new Date('2024-02-12T09:00:00Z'), + tags: [], + categories: [], + }; + + const draftWithPublishedVersion = { + id: 'post-draft-with-published', + projectId: 'test-project', + slug: 'draft-current-slug', + status: 'draft', + createdAt: new Date('2024-03-01T08:00:00Z'), + updatedAt: new Date('2024-03-03T08:00:00Z'), + tags: [], + categories: [], + }; + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return [publishedPost]; + } + if (filter.status === 'draft') { + return [neverPublishedDraft, draftWithPublishedVersion]; + } + return []; + }); + + mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => { + if (id !== 'post-draft-with-published') { + return null; + } + + return { + id, + projectId: 'test-project', + slug: 'published-snapshot-slug', + status: 'published', + createdAt: new Date('2023-10-05T07:00:00Z'), + updatedAt: new Date('2023-10-20T09:00:00Z'), + tags: [], + categories: [], + }; + }); + + const { writeFile, mkdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + mockTaskManager.runTask.mockImplementation(async (task: any) => { + const onProgress = vi.fn(); + return await task.execute(onProgress); + }); + + const result = await invokeHandler('blog:generateSitemap'); + + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'published' }); + expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'draft' }); + expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-new'); + expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-with-published'); + + expect(result.postCount).toBe(2); + + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + expect(sitemapXml).toContain('published-post'); + expect(sitemapXml).toContain('published-snapshot-slug'); + expect(sitemapXml).not.toContain('draft-no-published-version'); + expect(sitemapXml).not.toContain('draft-current-slug'); + }); + it('should use canonical path helpers for post URLs', async () => { const mockProject = createMockProject({ id: 'test-project', @@ -1631,8 +1746,12 @@ describe('IPC Handlers', () => { }); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com', + }); - const mockDbPosts = [ + const mockPublishedPosts = [ { id: 'post-1', projectId: 'test-project', @@ -1640,24 +1759,20 @@ describe('IPC Handlers', () => { status: 'published', createdAt: new Date('2024-03-25T10:00:00Z'), updatedAt: new Date('2024-03-26T15:00:00Z'), - tags: '[]', - categories: '[]', + tags: [], + categories: [], }, ]; - - const mockSelect = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn(() => ({ - all: vi.fn().mockResolvedValue(mockDbPosts), - })), - })), - })), - }; - - mockDatabase.getLocal.mockReturnValue({ - select: vi.fn(() => mockSelect), + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return mockPublishedPosts; + } + if (filter.status === 'draft') { + return []; + } + return []; }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); const { writeFile, mkdir } = await import('fs/promises'); vi.mocked(mkdir).mockResolvedValue(undefined); @@ -1674,7 +1789,83 @@ describe('IPC Handlers', () => { const sitemapXml = writeFileCall[1] as string; // Verify canonical URL format: /YYYY/MM/DD/slug - expect(sitemapXml).toContain('http://127.0.0.1:4123/2024/03/25/my-test-post'); + expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/my-test-post'); + }); + + it('should show setup dialog and abort when project public URL is missing', async () => { + const mockProject = createMockProject({ + id: 'test-project', + dataPath: '/mock/data', + }); + mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); + mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + }); + + const { dialog } = await import('electron'); + + await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('Project public URL is not configured'); + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'warning', + title: 'Public URL Required', + }), + ); + expect(mockTaskManager.runTask).not.toHaveBeenCalled(); + }); + + it('should use project public URL from metadata as sitemap base URL', async () => { + const mockProject = createMockProject({ + id: 'test-project', + dataPath: '/mock/data', + }); + mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); + mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com/', + }); + + mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => { + if (filter.status === 'published') { + return [ + { + id: 'post-1', + projectId: 'test-project', + slug: 'public-url-test-post', + status: 'published', + createdAt: new Date('2024-03-25T10:00:00Z'), + updatedAt: new Date('2024-03-26T15:00:00Z'), + tags: [], + categories: [], + }, + ]; + } + if (filter.status === 'draft') { + return []; + } + return []; + }); + mockPostEngine.getPublishedVersion.mockResolvedValue(null); + + const { writeFile, mkdir } = await import('fs/promises'); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + mockTaskManager.runTask.mockImplementation(async (task: any) => { + const onProgress = vi.fn(); + return await task.execute(onProgress); + }); + + await invokeHandler('blog:generateSitemap'); + + const writeFileCall = vi.mocked(writeFile).mock.calls[0]; + const sitemapXml = writeFileCall[1] as string; + + expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/public-url-test-post'); + expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post'); }); }); }); diff --git a/tests/renderer/components/SettingsView.test.tsx b/tests/renderer/components/SettingsView.test.tsx index 154a8ed..a9d42b9 100644 --- a/tests/renderer/components/SettingsView.test.tsx +++ b/tests/renderer/components/SettingsView.test.tsx @@ -43,8 +43,8 @@ describe('SettingsView Diff Preferences', () => { meta: { ...(window as any).electronAPI?.meta, getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), - getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75 }), - updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12 }), + getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com' }), + updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }), }, chat: { ...(window as any).electronAPI?.chat, @@ -92,4 +92,19 @@ describe('SettingsView Diff Preferences', () => { expect.objectContaining({ maxPostsPerPage: 75 }) ); }); + + it('includes project public URL in metadata save payload', async () => { + render(); + + await screen.findByDisplayValue('https://example.com'); + + const saveButton = screen.getByRole('button', { name: /save project settings/i }); + fireEvent.click(saveButton); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith( + expect.objectContaining({ publicUrl: 'https://example.com' }) + ); + }); });