From 696b79c5d7e997aebeb40e38d3336bf240a6f66a Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 27 Feb 2026 20:45:56 +0100 Subject: [PATCH] fix: race condition and delete checking for templates --- API.md | 24 ++- src/main/engine/TemplateEngine.ts | 114 +++++++++++--- src/main/ipc/handlers.ts | 4 +- src/main/preload.ts | 2 +- src/main/shared/electronApi.ts | 7 +- src/main/shared/pythonApiContractV1.ts | 12 +- src/renderer/components/Sidebar/Sidebar.tsx | 23 ++- .../TemplatesView/TemplatesView.tsx | 23 ++- src/renderer/i18n/locales/de.json | 1 + src/renderer/i18n/locales/en.json | 1 + src/renderer/i18n/locales/es.json | 1 + src/renderer/i18n/locales/fr.json | 1 + src/renderer/i18n/locales/it.json | 1 + tests/engine/TemplateEngine.test.ts | 148 ++++++++++++++++-- tests/ipc/handlers.test.ts | 15 +- .../components/TemplatesView.test.tsx | 4 +- .../python/pythonApiContractV1.test.ts | 2 +- tests/setup.ts | 2 +- 18 files changed, 334 insertions(+), 51 deletions(-) diff --git a/API.md b/API.md index 7da5d1e..127ff5c 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # API Documentation -Contract version: 1.8.0 +Contract version: 1.9.0 This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. @@ -2044,15 +2044,17 @@ None # or ### templates.delete -Delete template by id. +Delete template by id. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete. **Parameters** - id (str, required) +- options (dict, optional) **Response specification** -- Return type: `boolean` +- Return type: `TemplateDeleteResult` +- Data structures: `TemplateDeleteResult` **Example call** @@ -2064,7 +2066,10 @@ result = await bds.templates.delete(id='id-1') **Example response** ```python -True +{ + 'deleted': False, + 'references': 'value' +} ``` ### templates.get @@ -3889,6 +3894,17 @@ Liquid template definition for posts, lists, not-found pages, and partials. [↑ Back to Table of contents](#table-of-contents) +### TemplateDeleteResult + +Result of a template delete operation. If the template is referenced by posts or tags, deleted is false and references lists the referencing IDs. + +**Fields** + +- deleted (`boolean`, required): Whether the template was deleted. +- references (`{ postIds: string[]; tagIds: string[] }`, optional): Post and tag IDs referencing this template (present when deleted is false and references exist). + +[↑ Back to Table of contents](#table-of-contents) + ### TaskProgress Task queue status object for long-running operations. diff --git a/src/main/engine/TemplateEngine.ts b/src/main/engine/TemplateEngine.ts index 05348be..9a7e0ff 100644 --- a/src/main/engine/TemplateEngine.ts +++ b/src/main/engine/TemplateEngine.ts @@ -6,7 +6,7 @@ import { app } from 'electron'; import { and, desc, eq } from 'drizzle-orm'; import { Liquid } from 'liquidjs'; import { getDatabase } from '../database'; -import { templates, type NewTemplate, type Template } from '../database/schema'; +import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema'; export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial'; @@ -60,6 +60,11 @@ export interface TemplateValidationResult { errors: string[]; } +export interface TemplateDeleteResult { + deleted: boolean; + references?: { postIds: string[]; tagIds: string[] }; +} + interface ParsedTemplateFile { metadata: { id?: string; @@ -139,18 +144,13 @@ export class TemplateEngine extends EventEmitter { const nextFilePath = this.getTemplateFilePath(nextSlug); const now = new Date(); - if (existing.filePath !== nextFilePath) { - await fs.mkdir(this.getTemplatesDir(), { recursive: true }); - await fs.rename(existing.filePath, nextFilePath); - } - const nextTitle = updates.title ?? existing.title; const nextKind = updates.kind ?? existing.kind; const nextEnabled = updates.enabled ?? existing.enabled; const nextVersion = existing.version + 1; const nextContent = typeof updates.content === 'string' ? updates.content - : await this.readTemplateBody(nextFilePath); + : await this.readTemplateBody(existing.filePath); const nextRow = { ...existing, @@ -163,21 +163,58 @@ export class TemplateEngine extends EventEmitter { updatedAt: now, }; - await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8'); + const dbUpdates = { + title: nextTitle, + slug: nextSlug, + kind: nextKind, + enabled: nextEnabled, + filePath: nextFilePath, + version: nextVersion, + updatedAt: now, + }; + // DB-first: update the database row before touching the filesystem await getDatabase().getLocal() .update(templates) - .set({ - title: nextTitle, - slug: nextSlug, - kind: nextKind, - enabled: nextEnabled, - filePath: nextFilePath, - version: nextVersion, - updatedAt: now, - }) + .set(dbUpdates) .where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId))); + try { + if (existing.filePath !== nextFilePath) { + await fs.mkdir(this.getTemplatesDir(), { recursive: true }); + await fs.rename(existing.filePath, nextFilePath); + } + + await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8'); + } catch (fileError) { + // Roll back the DB row to previous values on file operation failure + await getDatabase().getLocal() + .update(templates) + .set({ + title: existing.title, + slug: existing.slug, + kind: existing.kind, + enabled: existing.enabled, + filePath: existing.filePath, + version: existing.version, + updatedAt: existing.updatedAt, + }) + .where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId))); + throw fileError; + } + + // Cascade slug updates to referencing posts and tags + if (existing.slug !== nextSlug) { + await getDatabase().getLocal() + .update(posts) + .set({ templateSlug: nextSlug }) + .where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId))); + await getDatabase().getLocal() + .update(tags) + .set({ postTemplateSlug: nextSlug }) + .where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId))); + } + const updatedRow = await this.getTemplateRow(existing.id); if (!updatedRow) { return null; @@ -188,10 +225,47 @@ export class TemplateEngine extends EventEmitter { return updated; } - async deleteTemplate(id: string): Promise { + async getTemplateReferences(slug: string): Promise<{ postIds: string[]; tagIds: string[] }> { + const db = getDatabase().getLocal(); + const referencingPosts = await db + .select({ id: posts.id }) + .from(posts) + .where(and(eq(posts.templateSlug, slug), eq(posts.projectId, this.currentProjectId))) + .all(); + const referencingTags = await db + .select({ id: tags.id }) + .from(tags) + .where(and(eq(tags.postTemplateSlug, slug), eq(tags.projectId, this.currentProjectId))) + .all(); + return { + postIds: referencingPosts.map((row) => row.id), + tagIds: referencingTags.map((row) => row.id), + }; + } + + async deleteTemplate(id: string, options?: { force?: boolean }): Promise { const existing = await this.getTemplateRow(id); if (!existing) { - return false; + return { deleted: false }; + } + + const refs = await this.getTemplateReferences(existing.slug); + const hasReferences = refs.postIds.length > 0 || refs.tagIds.length > 0; + + if (hasReferences && !options?.force) { + return { deleted: false, references: refs }; + } + + if (hasReferences && options?.force) { + const db = getDatabase().getLocal(); + await db + .update(posts) + .set({ templateSlug: null }) + .where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId))); + await db + .update(tags) + .set({ postTemplateSlug: null }) + .where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId))); } await getDatabase().getLocal() @@ -208,7 +282,7 @@ export class TemplateEngine extends EventEmitter { } this.emit('templateDeleted', id); - return true; + return { deleted: true }; } async getTemplate(id: string): Promise { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ed295d1..055a6b1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -817,9 +817,9 @@ export function registerIpcHandlers(): void { return engine.updateTemplate(id, data); }); - safeHandle('templates:delete', async (_, id: string) => { + safeHandle('templates:delete', async (_, id: string, options?: { force?: boolean }) => { const engine = getTemplateEngine(); - return engine.deleteTemplate(id); + return engine.deleteTemplate(id, options); }); safeHandle('templates:get', async (_, id: string) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 336abe5..dcad04f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -116,7 +116,7 @@ export const electronAPI: ElectronAPI = { templates: { create: (data: { title: string; kind: import('./shared/electronApi').TemplateKind; content: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:create', data), update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').TemplateKind; content?: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:update', id, data), - delete: (id: string) => ipcRenderer.invoke('templates:delete', id), + delete: (id: string, options?: { force?: boolean }) => ipcRenderer.invoke('templates:delete', id, options), get: (id: string) => ipcRenderer.invoke('templates:get', id), getAll: () => ipcRenderer.invoke('templates:getAll'), getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index b2ee196..0ae58b6 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -177,6 +177,11 @@ export interface TemplateData { updatedAt: string; } +export interface TemplateDeleteResult { + deleted: boolean; + references?: { postIds: string[]; tagIds: string[] }; +} + export interface TaskProgress { taskId: string; name: string; @@ -623,7 +628,7 @@ export interface ElectronAPI { slug?: string; enabled?: boolean; }) => Promise; - delete: (id: string) => Promise; + delete: (id: string, options?: { force?: boolean }) => Promise; get: (id: string) => Promise; getAll: () => Promise; getEnabledByKind: (kind: TemplateKind) => Promise; diff --git a/src/main/shared/pythonApiContractV1.ts b/src/main/shared/pythonApiContractV1.ts index 71227b4..e44f93e 100644 --- a/src/main/shared/pythonApiContractV1.ts +++ b/src/main/shared/pythonApiContractV1.ts @@ -131,7 +131,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ method('templates.create', 'Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).', [requiredObject('data')], 'TemplateData'), method('templates.update', 'Update template by id. data may include any of: title, kind, content, slug, enabled.', [requiredString('id'), requiredObject('data')], 'TemplateData | null'), - method('templates.delete', 'Delete template by id.', [requiredString('id')], 'boolean'), + method('templates.delete', 'Delete template by id. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete.', [requiredString('id'), optionalObject('options')], 'TemplateDeleteResult'), method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'), method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'), method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'), @@ -302,6 +302,14 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, ], }, + { + name: 'TemplateDeleteResult', + description: 'Result of a template delete operation. If the template is referenced by posts or tags, deleted is false and references lists the referencing IDs.', + fields: [ + { name: 'deleted', type: 'boolean', required: true, description: 'Whether the template was deleted.' }, + { name: 'references', type: '{ postIds: string[]; tagIds: string[] }', required: false, description: 'Post and tag IDs referencing this template (present when deleted is false and references exist).' }, + ], + }, { name: 'TaskProgress', description: 'Task queue status object for long-running operations.', @@ -396,7 +404,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ ]; export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { - version: '1.8.0', + version: '1.9.0', generatedAt: '2026-02-27T00:00:00.000Z', methods: METHODS_V1, dataStructures: DATA_STRUCTURES_V1, diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index a1bc0ce..8b3ed7f 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1752,11 +1752,30 @@ const TemplatesList: React.FC = () => { const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => { event.stopPropagation(); try { - const deleted = await window.electronAPI?.templates.delete(templateId); - if (!deleted) { + const result = await window.electronAPI?.templates.delete(templateId); + if (!result) { showToast.error(t('sidebar.templates.deleteFailed')); return; } + + if (!result.deleted && result.references) { + const { postIds, tagIds } = result.references; + const confirmed = window.confirm( + t('sidebar.templates.deleteConfirmWithRefs', { + postCount: String(postIds.length), + tagCount: String(tagIds.length), + }), + ); + if (!confirmed) { + return; + } + const forceResult = await window.electronAPI?.templates.delete(templateId, { force: true }); + if (!forceResult?.deleted) { + showToast.error(t('sidebar.templates.deleteFailed')); + return; + } + } + setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId)); closeTab(templateId); dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED); diff --git a/src/renderer/components/TemplatesView/TemplatesView.tsx b/src/renderer/components/TemplatesView/TemplatesView.tsx index e6073a4..4d38127 100644 --- a/src/renderer/components/TemplatesView/TemplatesView.tsx +++ b/src/renderer/components/TemplatesView/TemplatesView.tsx @@ -182,11 +182,30 @@ export const TemplatesView: React.FC = ({ templateId }) => { } try { - const deleted = await window.electronAPI?.templates.delete(template.id); - if (!deleted) { + const result = await window.electronAPI?.templates.delete(template.id); + if (!result) { showToast.error(t('sidebar.templates.deleteFailed')); return; } + + if (!result.deleted && result.references) { + const { postIds, tagIds } = result.references; + const confirmed = window.confirm( + t('sidebar.templates.deleteConfirmWithRefs', { + postCount: String(postIds.length), + tagCount: String(tagIds.length), + }), + ); + if (!confirmed) { + return; + } + const forceResult = await window.electronAPI?.templates.delete(template.id, { force: true }); + if (!forceResult?.deleted) { + showToast.error(t('sidebar.templates.deleteFailed')); + return; + } + } + closeTab(template.id); dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED); } catch (error) { diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 42d2974..89445c3 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -780,6 +780,7 @@ "sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden", "sidebar.templates.deleteTemplate": "Vorlage löschen", "sidebar.templates.deleteFailed": "Vorlage konnte nicht gelöscht werden", + "sidebar.templates.deleteConfirmWithRefs": "Diese Vorlage wird von {postCount} Beitrag/Beiträgen und {tagCount} Tag(s) referenziert. Trotzdem löschen? Die Referenzen werden entfernt.", "sidebar.import.none": "Noch keine Importdefinitionen", "sidebar.import.createDefinition": "Eine Importdefinition erstellen", "sidebar.import.deleteDefinition": "Importdefinition löschen", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index d6e70f6..4aa451b 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -780,6 +780,7 @@ "sidebar.templates.createFailed": "Failed to create template", "sidebar.templates.deleteTemplate": "Delete template", "sidebar.templates.deleteFailed": "Failed to delete template", + "sidebar.templates.deleteConfirmWithRefs": "This template is referenced by {postCount} post(s) and {tagCount} tag(s). Delete anyway? References will be cleared.", "sidebar.import.none": "No import definitions yet", "sidebar.import.createDefinition": "Create an import definition", "sidebar.import.deleteDefinition": "Delete import definition", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 1b9bb36..ab81153 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -780,6 +780,7 @@ "sidebar.templates.createFailed": "No se pudo crear la plantilla", "sidebar.templates.deleteTemplate": "Eliminar plantilla", "sidebar.templates.deleteFailed": "No se pudo eliminar la plantilla", + "sidebar.templates.deleteConfirmWithRefs": "Esta plantilla está referenciada por {postCount} entrada(s) y {tagCount} etiqueta(s). ¿Eliminar de todos modos? Las referencias serán eliminadas.", "sidebar.import.none": "Sin definiciones de importación", "sidebar.import.createDefinition": "Crear definición", "sidebar.import.deleteDefinition": "Eliminar definición", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 69c0587..92fdd9d 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -778,6 +778,7 @@ "sidebar.templates.createFailed": "Impossible de créer le modèle", "sidebar.templates.deleteTemplate": "Supprimer le modèle", "sidebar.templates.deleteFailed": "Impossible de supprimer le modèle", + "sidebar.templates.deleteConfirmWithRefs": "Ce modèle est référencé par {postCount} article(s) et {tagCount} tag(s). Supprimer quand même ? Les références seront supprimées.", "sidebar.import.none": "Aucune définition d’import", "sidebar.import.createDefinition": "Créer une définition", "sidebar.import.deleteDefinition": "Supprimer la définition", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index de0504f..defbbec 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -778,6 +778,7 @@ "sidebar.templates.createFailed": "Impossibile creare il modello", "sidebar.templates.deleteTemplate": "Elimina modello", "sidebar.templates.deleteFailed": "Impossibile eliminare il modello", + "sidebar.templates.deleteConfirmWithRefs": "Questo modello è referenziato da {postCount} articolo/i e {tagCount} tag. Eliminare comunque? I riferimenti verranno rimossi.", "sidebar.import.none": "Nessuna definizione di importazione", "sidebar.import.createDefinition": "Crea definizione", "sidebar.import.deleteDefinition": "Elimina definizione", diff --git a/tests/engine/TemplateEngine.test.ts b/tests/engine/TemplateEngine.test.ts index 6923cc9..c4ca99f 100644 --- a/tests/engine/TemplateEngine.test.ts +++ b/tests/engine/TemplateEngine.test.ts @@ -1,33 +1,53 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TemplateEngine } from '../../src/main/engine/TemplateEngine'; +import { posts, tags, templates } from '../../src/main/database/schema'; const mockTemplates = new Map(); +const mockPosts = new Map(); +const mockTags = new Map(); const mockFiles = new Map(); -function createSelectChain() { +function createSelectChain(dataSource: () => any[]) { return { - from: vi.fn().mockReturnThis(), + from: vi.fn(function (this: any, table: any) { + if (table === posts) { + this.all = vi.fn(() => Promise.resolve(Array.from(mockPosts.values()))); + } else if (table === tags) { + this.all = vi.fn(() => Promise.resolve(Array.from(mockTags.values()))); + } + return this; + }), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockReturnThis(), - all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTemplates.values()))), + all: vi.fn().mockImplementation(() => Promise.resolve(dataSource())), get: vi.fn().mockImplementation(() => Promise.resolve(undefined)), }; } function createDrizzleMock() { return { - select: vi.fn(() => createSelectChain()), + select: vi.fn(() => createSelectChain(() => Array.from(mockTemplates.values()))), insert: vi.fn(() => ({ values: vi.fn((data: any) => { mockTemplates.set(data.id, data); return Promise.resolve(); }), })), - update: vi.fn(() => ({ + update: vi.fn((table?: any) => ({ set: vi.fn((updates: any) => ({ where: vi.fn(async () => { - for (const [templateId, existing] of mockTemplates.entries()) { - mockTemplates.set(templateId, { ...existing, ...updates }); + if (table === posts) { + for (const [postId, existing] of mockPosts.entries()) { + mockPosts.set(postId, { ...existing, ...updates }); + } + } else if (table === tags) { + for (const [tagId, existing] of mockTags.entries()) { + mockTags.set(tagId, { ...existing, ...updates }); + } + } else { + for (const [templateId, existing] of mockTemplates.entries()) { + mockTemplates.set(templateId, { ...existing, ...updates }); + } } }), })), @@ -101,9 +121,11 @@ describe('TemplateEngine', () => { beforeEach(() => { vi.clearAllMocks(); mockTemplates.clear(); + mockPosts.clear(); + mockTags.clear(); mockFiles.clear(); (globalThis as any).__mockTemplateFiles = mockFiles; - vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain()); + vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain(() => Array.from(mockTemplates.values()))); templateEngine = new TemplateEngine(); templateEngine.setProjectContext('default', '/mock/userData/projects/default'); @@ -170,9 +192,9 @@ describe('TemplateEngine', () => { content: '
Footer
', }); - const deleted = await templateEngine.deleteTemplate(created.id); + const result = await templateEngine.deleteTemplate(created.id); - expect(deleted).toBe(true); + expect(result).toEqual({ deleted: true }); expect(mockTemplates.has(created.id)).toBe(false); expect(mockFiles.has('/mock/userData/projects/default/templates/delete_me.liquid')).toBe(false); }); @@ -389,4 +411,110 @@ describe('TemplateEngine', () => { expect(found).toBeNull(); }); }); + + describe('referential integrity', () => { + it('getTemplateReferences returns empty arrays when no references exist', async () => { + const refs = await templateEngine.getTemplateReferences('custom_post'); + expect(refs).toEqual({ postIds: [], tagIds: [] }); + }); + + it('getTemplateReferences returns referencing post and tag IDs', async () => { + mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: 'custom_post' }); + mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: 'custom_post' }); + + const refs = await templateEngine.getTemplateReferences('custom_post'); + expect(refs.postIds).toContain('post-1'); + expect(refs.tagIds).toContain('tag-1'); + }); + + it('deleteTemplate blocks deletion when references exist and force is not set', async () => { + const created = await templateEngine.createTemplate({ + title: 'Referenced Template', + kind: 'post', + content: '
Referenced
', + }); + + mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug }); + + const result = await templateEngine.deleteTemplate(created.id); + + expect(result.deleted).toBe(false); + expect(result.references?.postIds).toContain('post-1'); + expect(mockTemplates.has(created.id)).toBe(true); + }); + + it('deleteTemplate with force clears references and deletes', async () => { + const created = await templateEngine.createTemplate({ + title: 'Force Delete', + kind: 'post', + content: '
Force
', + }); + + mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug }); + mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: created.slug }); + + const result = await templateEngine.deleteTemplate(created.id, { force: true }); + + expect(result.deleted).toBe(true); + expect(mockTemplates.has(created.id)).toBe(false); + expect(mockPosts.get('post-1').templateSlug).toBeNull(); + expect(mockTags.get('tag-1').postTemplateSlug).toBeNull(); + }); + }); + + describe('slug cascade on rename', () => { + it('cascades slug changes to posts.templateSlug on template rename', async () => { + const created = await templateEngine.createTemplate({ + title: 'Original Name', + kind: 'post', + content: '
Content
', + }); + + mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug }); + + await templateEngine.updateTemplate(created.id, { title: 'Renamed Template' }); + + expect(mockPosts.get('post-1').templateSlug).toBe('renamed_template'); + }); + + it('cascades slug changes to tags.postTemplateSlug on template rename', async () => { + const created = await templateEngine.createTemplate({ + title: 'Original Name', + kind: 'post', + content: '
Content
', + }); + + mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: created.slug }); + + await templateEngine.updateTemplate(created.id, { title: 'Renamed Template' }); + + expect(mockTags.get('tag-1').postTemplateSlug).toBe('renamed_template'); + }); + }); + + describe('race condition protection', () => { + it('rolls back DB on file operation failure during update', async () => { + const created = await templateEngine.createTemplate({ + title: 'Rollback Test', + kind: 'post', + content: '
Original
', + }); + + const originalSlug = created.slug; + const originalTitle = created.title; + + // Make fs.rename throw to simulate file operation failure + const fsModule = await import('fs/promises'); + vi.mocked(fsModule.rename).mockRejectedValueOnce(new Error('EPERM')); + + await expect( + templateEngine.updateTemplate(created.id, { title: 'Should Fail' }), + ).rejects.toThrow('EPERM'); + + // DB should have been rolled back to original values + const row = mockTemplates.get(created.id); + expect(row.slug).toBe(originalSlug); + expect(row.title).toBe(originalTitle); + }); + }); }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index d80da41..0a14096 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -2844,12 +2844,21 @@ describe('IPC Handlers', () => { describe('templates:delete', () => { it('should call TemplateEngine.deleteTemplate with id', async () => { - mockTemplateEngine.deleteTemplate.mockResolvedValue(true); + mockTemplateEngine.deleteTemplate.mockResolvedValue({ deleted: true }); const result = await invokeHandler('templates:delete', 'template-1'); - expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1'); - expect(result).toBe(true); + expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1', undefined); + expect(result).toEqual({ deleted: true }); + }); + + it('should forward force option to TemplateEngine.deleteTemplate', async () => { + mockTemplateEngine.deleteTemplate.mockResolvedValue({ deleted: true }); + + const result = await invokeHandler('templates:delete', 'template-1', { force: true }); + + expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1', { force: true }); + expect(result).toEqual({ deleted: true }); }); }); diff --git a/tests/renderer/components/TemplatesView.test.tsx b/tests/renderer/components/TemplatesView.test.tsx index 9fa9493..9e870d6 100644 --- a/tests/renderer/components/TemplatesView.test.tsx +++ b/tests/renderer/components/TemplatesView.test.tsx @@ -47,7 +47,7 @@ describe('TemplatesView', () => { templates: { create: vi.fn(), update: vi.fn(), - delete: vi.fn(), + delete: vi.fn().mockResolvedValue({ deleted: true }), get: vi.fn().mockResolvedValue({ ...mockTemplate }), getAll: vi.fn().mockResolvedValue([]), getEnabledByKind: vi.fn().mockResolvedValue([]), @@ -180,7 +180,7 @@ describe('TemplatesView', () => { }); it('deletes template and closes tab', async () => { - const deleteMock = vi.fn().mockResolvedValue(true); + const deleteMock = vi.fn().mockResolvedValue({ deleted: true }); (window as any).electronAPI.templates.delete = deleteMock; useAppStore.setState({ diff --git a/tests/renderer/python/pythonApiContractV1.test.ts b/tests/renderer/python/pythonApiContractV1.test.ts index 79a66c8..ba1347a 100644 --- a/tests/renderer/python/pythonApiContractV1.test.ts +++ b/tests/renderer/python/pythonApiContractV1.test.ts @@ -67,7 +67,7 @@ describe('pythonApiContractV1', () => { it('contains semantic version metadata for compatibility checks', () => { expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ - version: '1.8.0', + version: '1.9.0', generatedAt: expect.any(String), }); }); diff --git a/tests/setup.ts b/tests/setup.ts index e6c70ed..d1f0c2a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -152,7 +152,7 @@ Object.defineProperty(globalThis, 'window', { get: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(null), update: vi.fn().mockResolvedValue(null), - delete: vi.fn().mockResolvedValue(false), + delete: vi.fn().mockResolvedValue({ deleted: true }), validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), rebuildFromFiles: vi.fn().mockResolvedValue(undefined), },