fix: hooked up python API for templates
This commit is contained in:
312
API.md
312
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
Contract version: 1.7.0
|
Contract version: 1.8.0
|
||||||
|
|
||||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ project = await bds.meta.get_project_metadata()
|
|||||||
- [posts](#posts)
|
- [posts](#posts)
|
||||||
- [media](#media)
|
- [media](#media)
|
||||||
- [scripts](#scripts)
|
- [scripts](#scripts)
|
||||||
|
- [templates](#templates)
|
||||||
- [tasks](#tasks)
|
- [tasks](#tasks)
|
||||||
- [app](#app)
|
- [app](#app)
|
||||||
- [meta](#meta)
|
- [meta](#meta)
|
||||||
@@ -1949,6 +1950,295 @@ None
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
|
## templates
|
||||||
|
|
||||||
|
**Module APIs**
|
||||||
|
|
||||||
|
- [templates.create](#templatescreate)
|
||||||
|
- [templates.update](#templatesupdate)
|
||||||
|
- [templates.delete](#templatesdelete)
|
||||||
|
- [templates.get](#templatesget)
|
||||||
|
- [templates.getAll](#templatesgetall)
|
||||||
|
- [templates.getEnabledByKind](#templatesgetenabledbykind)
|
||||||
|
- [templates.validate](#templatesvalidate)
|
||||||
|
- [templates.rebuildFromFiles](#templatesrebuildfromfiles)
|
||||||
|
|
||||||
|
### templates.create
|
||||||
|
|
||||||
|
Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- data (dict, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateData`
|
||||||
|
- Data structures: `TemplateData`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.create(data={})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'id': 'value',
|
||||||
|
'projectId': 'value',
|
||||||
|
'slug': 'value',
|
||||||
|
'title': 'value',
|
||||||
|
'kind': 'post',
|
||||||
|
'enabled': False,
|
||||||
|
'version': 0,
|
||||||
|
'filePath': 'value',
|
||||||
|
'content': 'value',
|
||||||
|
'createdAt': 'value',
|
||||||
|
'updatedAt': 'value'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.update
|
||||||
|
|
||||||
|
Update template by id. data may include any of: title, kind, content, slug, enabled.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- id (str, required)
|
||||||
|
- data (dict, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateData | null`
|
||||||
|
- Nullability: Returns `None` when no matching value exists.
|
||||||
|
- Data structures: `TemplateData`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.update(id='id-1', data={})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None # or
|
||||||
|
{
|
||||||
|
'id': 'value',
|
||||||
|
'projectId': 'value',
|
||||||
|
'slug': 'value',
|
||||||
|
'title': 'value',
|
||||||
|
'kind': 'post',
|
||||||
|
'enabled': False,
|
||||||
|
'version': 0,
|
||||||
|
'filePath': 'value',
|
||||||
|
'content': 'value',
|
||||||
|
'createdAt': 'value',
|
||||||
|
'updatedAt': 'value'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.delete
|
||||||
|
|
||||||
|
Delete template by id.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- id (str, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.delete(id='id-1')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
True
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.get
|
||||||
|
|
||||||
|
Fetch template by id.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- id (str, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateData | null`
|
||||||
|
- Nullability: Returns `None` when no matching value exists.
|
||||||
|
- Data structures: `TemplateData`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.get(id='id-1')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None # or
|
||||||
|
{
|
||||||
|
'id': 'value',
|
||||||
|
'projectId': 'value',
|
||||||
|
'slug': 'value',
|
||||||
|
'title': 'value',
|
||||||
|
'kind': 'post',
|
||||||
|
'enabled': False,
|
||||||
|
'version': 0,
|
||||||
|
'filePath': 'value',
|
||||||
|
'content': 'value',
|
||||||
|
'createdAt': 'value',
|
||||||
|
'updatedAt': 'value'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.getAll
|
||||||
|
|
||||||
|
Fetch all templates.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateData[]`
|
||||||
|
- Data structures: `TemplateData`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.get_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': 'value',
|
||||||
|
'projectId': 'value',
|
||||||
|
'slug': 'value',
|
||||||
|
'title': 'value',
|
||||||
|
'kind': 'post',
|
||||||
|
'enabled': False,
|
||||||
|
'version': 0,
|
||||||
|
'filePath': 'value',
|
||||||
|
'content': 'value',
|
||||||
|
'createdAt': 'value',
|
||||||
|
'updatedAt': 'value'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.getEnabledByKind
|
||||||
|
|
||||||
|
Fetch enabled templates filtered by kind.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- kind (str, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateData[]`
|
||||||
|
- Data structures: `TemplateData`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.get_enabled_by_kind(kind='kind')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': 'value',
|
||||||
|
'projectId': 'value',
|
||||||
|
'slug': 'value',
|
||||||
|
'title': 'value',
|
||||||
|
'kind': 'post',
|
||||||
|
'enabled': False,
|
||||||
|
'version': 0,
|
||||||
|
'filePath': 'value',
|
||||||
|
'content': 'value',
|
||||||
|
'createdAt': 'value',
|
||||||
|
'updatedAt': 'value'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.validate
|
||||||
|
|
||||||
|
Validate Liquid template syntax.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- content (str, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `{ valid: boolean; errors: string[] }`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.validate(content='content')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
### templates.rebuildFromFiles
|
||||||
|
|
||||||
|
Rebuild templates from files.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `void`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.rebuild_from_files()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None
|
||||||
|
```
|
||||||
|
|
||||||
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
## tasks
|
## tasks
|
||||||
|
|
||||||
**Module APIs**
|
**Module APIs**
|
||||||
@@ -3579,6 +3869,26 @@ Script definition for Python macros, utilities, and transforms.
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
|
### TemplateData
|
||||||
|
|
||||||
|
Liquid template definition for posts, lists, not-found pages, and partials.
|
||||||
|
|
||||||
|
**Fields**
|
||||||
|
|
||||||
|
- id (`string`, required): Unique template identifier.
|
||||||
|
- projectId (`string`, required): Owning project id.
|
||||||
|
- slug (`string`, required): Stable template slug.
|
||||||
|
- title (`string`, required): Human-readable template title.
|
||||||
|
- kind (`'post' | 'list' | 'not-found' | 'partial'`, required): Template category.
|
||||||
|
- enabled (`boolean`, required): Whether template is enabled.
|
||||||
|
- version (`number`, required): Incrementing template version.
|
||||||
|
- filePath (`string`, required): Filesystem path to template file.
|
||||||
|
- content (`string`, required): Liquid template source code.
|
||||||
|
- createdAt (`string`, required): Creation timestamp (ISO string).
|
||||||
|
- updatedAt (`string`, required): Last update timestamp (ISO string).
|
||||||
|
|
||||||
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
### TaskProgress
|
### TaskProgress
|
||||||
|
|
||||||
Task queue status object for long-running operations.
|
Task queue status object for long-running operations.
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
||||||
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
|
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
|
||||||
|
|
||||||
|
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.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[]'),
|
||||||
|
method('templates.validate', 'Validate Liquid template syntax.', [requiredString('content')], '{ valid: boolean; errors: string[] }'),
|
||||||
|
method('templates.rebuildFromFiles', 'Rebuild templates from files.', [], 'void'),
|
||||||
|
|
||||||
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
|
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
|
||||||
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
||||||
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
||||||
@@ -276,6 +285,23 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
|||||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'TemplateData',
|
||||||
|
description: 'Liquid template definition for posts, lists, not-found pages, and partials.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique template identifier.' },
|
||||||
|
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||||
|
{ name: 'slug', type: 'string', required: true, description: 'Stable template slug.' },
|
||||||
|
{ name: 'title', type: 'string', required: true, description: 'Human-readable template title.' },
|
||||||
|
{ name: 'kind', type: "'post' | 'list' | 'not-found' | 'partial'", required: true, description: 'Template category.' },
|
||||||
|
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether template is enabled.' },
|
||||||
|
{ name: 'version', type: 'number', required: true, description: 'Incrementing template version.' },
|
||||||
|
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to template file.' },
|
||||||
|
{ name: 'content', type: 'string', required: true, description: 'Liquid template source code.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskProgress',
|
name: 'TaskProgress',
|
||||||
description: 'Task queue status object for long-running operations.',
|
description: 'Task queue status object for long-running operations.',
|
||||||
@@ -370,7 +396,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
version: '1.7.0',
|
version: '1.8.0',
|
||||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||||
methods: METHODS_V1,
|
methods: METHODS_V1,
|
||||||
dataStructures: DATA_STRUCTURES_V1,
|
dataStructures: DATA_STRUCTURES_V1,
|
||||||
|
|||||||
@@ -343,4 +343,50 @@ describe('TemplateEngine', () => {
|
|||||||
expect(created.slug).toBe('my_custom_slug');
|
expect(created.slug).toBe('my_custom_slug');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getTemplateBySlug', () => {
|
||||||
|
it('retrieves an enabled template by exact slug', async () => {
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Post</main>',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await templateEngine.getTemplateBySlug('custom_post');
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
expect(found?.slug).toBe('custom_post');
|
||||||
|
expect(found?.title).toBe('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches slugs case-insensitively', async () => {
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Post</main>',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await templateEngine.getTemplateBySlug('CUSTOM_POST');
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
expect(found?.slug).toBe('custom_post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-existent slug', async () => {
|
||||||
|
const found = await templateEngine.getTemplateBySlug('does_not_exist');
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return disabled templates', async () => {
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'Disabled Template',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Disabled</main>',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await templateEngine.getTemplateBySlug('disabled_template');
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2786,6 +2786,151 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Template Handlers ============
|
||||||
|
describe('Template Handlers', () => {
|
||||||
|
describe('templates:create', () => {
|
||||||
|
it('should call TemplateEngine.createTemplate with payload', async () => {
|
||||||
|
const payload = {
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<html>{{ post.title }}</html>',
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
...payload,
|
||||||
|
slug: 'custom_post',
|
||||||
|
enabled: true,
|
||||||
|
version: 1,
|
||||||
|
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTemplateEngine.createTemplate.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:create', payload);
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith(payload);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:update', () => {
|
||||||
|
it('should call TemplateEngine.updateTemplate with id and updates', async () => {
|
||||||
|
const updates = { title: 'Updated Template', content: '<html>{{ post.content }}</html>' };
|
||||||
|
const expected = {
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'updated_template',
|
||||||
|
title: 'Updated Template',
|
||||||
|
kind: 'post',
|
||||||
|
enabled: true,
|
||||||
|
version: 2,
|
||||||
|
filePath: '/mock/userData/projects/default/templates/updated_template.liquid',
|
||||||
|
content: '<html>{{ post.content }}</html>',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTemplateEngine.updateTemplate.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:update', 'template-1', updates);
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.updateTemplate).toHaveBeenCalledWith('template-1', updates);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:delete', () => {
|
||||||
|
it('should call TemplateEngine.deleteTemplate with id', async () => {
|
||||||
|
mockTemplateEngine.deleteTemplate.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:delete', 'template-1');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:get', () => {
|
||||||
|
it('should call TemplateEngine.getTemplate with id', async () => {
|
||||||
|
const expected = {
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'custom_post',
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
enabled: true,
|
||||||
|
version: 1,
|
||||||
|
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
|
||||||
|
content: '<html>{{ post.title }}</html>',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
mockTemplateEngine.getTemplate.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:get', 'template-1');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.getTemplate).toHaveBeenCalledWith('template-1');
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:getAll', () => {
|
||||||
|
it('should call TemplateEngine.getAllTemplates', async () => {
|
||||||
|
const expected = [{ id: 'template-1' }, { id: 'template-2' }];
|
||||||
|
mockTemplateEngine.getAllTemplates.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:getAll');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.getAllTemplates).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:getEnabledByKind', () => {
|
||||||
|
it('should call TemplateEngine.getEnabledTemplatesByKind with kind', async () => {
|
||||||
|
const expected = [{ id: 'template-1', kind: 'post' }];
|
||||||
|
mockTemplateEngine.getEnabledTemplatesByKind.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:getEnabledByKind', 'post');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.getEnabledTemplatesByKind).toHaveBeenCalledWith('post');
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:validate', () => {
|
||||||
|
it('should call TemplateEngine.validateTemplate with content', async () => {
|
||||||
|
const expected = { valid: true, errors: [] };
|
||||||
|
mockTemplateEngine.validateTemplate.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:validate', '<html>{{ post.title }}</html>');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<html>{{ post.title }}</html>');
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('templates:rebuildFromFiles', () => {
|
||||||
|
it('should set project context and trigger TemplateEngine rebuild', async () => {
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue({
|
||||||
|
id: 'project-1',
|
||||||
|
dataPath: '/external/data',
|
||||||
|
});
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||||
|
mockTemplateEngine.rebuildDatabaseFromFiles.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:rebuildFromFiles');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.setProjectContext).toHaveBeenCalledWith('project-1', '/resolved/project-data');
|
||||||
|
expect(mockTemplateEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Error Handling ============
|
// ============ Error Handling ============
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should silently handle "Database is closing" errors', async () => {
|
it('should silently handle "Database is closing" errors', async () => {
|
||||||
|
|||||||
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal file
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
describe('TemplatesView styles', () => {
|
||||||
|
const cssPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/components/TemplatesView/TemplatesView.css'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses full editor area layout for the templates container', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.templates-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps editor and monaco stretched to fill available space', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.templates-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||||
|
expect(css).toMatch(/\.templates-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
|
||||||
|
});
|
||||||
|
});
|
||||||
211
tests/renderer/components/TemplatesView.test.tsx
Normal file
211
tests/renderer/components/TemplatesView.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { TemplatesView } from '../../../src/renderer/components/TemplatesView/TemplatesView';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
const monacoPropsSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
default: (props: {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange?: (value?: string) => void;
|
||||||
|
language?: string;
|
||||||
|
}) => {
|
||||||
|
monacoPropsSpy(props);
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
aria-label="Template Content"
|
||||||
|
defaultValue={props.defaultValue ?? props.value ?? ''}
|
||||||
|
onChange={(event) => props.onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockTemplate = {
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'custom-post',
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post' as const,
|
||||||
|
enabled: true,
|
||||||
|
version: 1,
|
||||||
|
filePath: '/tmp/custom-post.liquid',
|
||||||
|
content: '<main>{{ post.title }}</main>',
|
||||||
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TemplatesView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
templates: {
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
get: vi.fn().mockResolvedValue({ ...mockTemplate }),
|
||||||
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
|
rebuildFromFiles: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads template and displays metadata fields', async () => {
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
const titleInput = await screen.findByLabelText('Title') as HTMLInputElement;
|
||||||
|
const slugInput = screen.getByLabelText('Slug') as HTMLInputElement;
|
||||||
|
const kindSelect = screen.getByLabelText('Kind') as HTMLSelectElement;
|
||||||
|
const enabledInput = screen.getByLabelText('Enabled') as HTMLInputElement;
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(titleInput.value).toBe('Custom Post');
|
||||||
|
expect(slugInput.value).toBe('custom-post');
|
||||||
|
});
|
||||||
|
expect(kindSelect.value).toBe('post');
|
||||||
|
expect(enabledInput.checked).toBe(true);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Created:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads template content into Monaco editor with html language', async () => {
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textarea = screen.getByLabelText('Template Content') as HTMLTextAreaElement;
|
||||||
|
expect(textarea.value).toContain('{{ post.title }}');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(monacoPropsSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
language: 'html',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves template metadata via update IPC call', async () => {
|
||||||
|
const updateMock = vi.fn().mockResolvedValue({
|
||||||
|
...mockTemplate,
|
||||||
|
kind: 'list',
|
||||||
|
enabled: false,
|
||||||
|
version: 2,
|
||||||
|
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.templates.update = updateMock;
|
||||||
|
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
const kindSelect = screen.getByLabelText('Kind');
|
||||||
|
const enabledInput = screen.getByLabelText('Enabled');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(kindSelect, { target: { value: 'list' } });
|
||||||
|
fireEvent.click(enabledInput);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((kindSelect as HTMLSelectElement).value).toBe('list');
|
||||||
|
expect((enabledInput as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(updateMock).toHaveBeenCalledWith(
|
||||||
|
'template-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Custom Post',
|
||||||
|
slug: 'custom-post',
|
||||||
|
kind: 'list',
|
||||||
|
enabled: false,
|
||||||
|
content: '<main>{{ post.title }}</main>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates before saving and blocks save on invalid syntax', async () => {
|
||||||
|
const validateMock = vi.fn().mockResolvedValue({
|
||||||
|
valid: false,
|
||||||
|
errors: ['unexpected end of tag'],
|
||||||
|
});
|
||||||
|
const updateMock = vi.fn();
|
||||||
|
(window as any).electronAPI.templates.validate = validateMock;
|
||||||
|
(window as any).electronAPI.templates.update = updateMock;
|
||||||
|
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger hasChanges via kind change (select events work reliably)
|
||||||
|
fireEvent.change(screen.getByLabelText('Kind'), { target: { value: 'list' } });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((screen.getByLabelText('Kind') as HTMLSelectElement).value).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(validateMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates template content via validate button', async () => {
|
||||||
|
const validateMock = vi.fn().mockResolvedValue({ valid: true, errors: [] });
|
||||||
|
(window as any).electronAPI.templates.validate = validateMock;
|
||||||
|
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await screen.findByLabelText('Title');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(validateMock).toHaveBeenCalledWith('<main>{{ post.title }}</main>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes template and closes tab', async () => {
|
||||||
|
const deleteMock = vi.fn().mockResolvedValue(true);
|
||||||
|
(window as any).electronAPI.templates.delete = deleteMock;
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
|
||||||
|
activeTabId: 'template-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Delete Template' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith('template-1');
|
||||||
|
expect(useAppStore.getState().tabs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables save button when no changes exist', async () => {
|
||||||
|
render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await screen.findByLabelText('Title');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'Save Template' });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,7 +67,7 @@ describe('pythonApiContractV1', () => {
|
|||||||
|
|
||||||
it('contains semantic version metadata for compatibility checks', () => {
|
it('contains semantic version metadata for compatibility checks', () => {
|
||||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||||
version: '1.7.0',
|
version: '1.8.0',
|
||||||
generatedAt: expect.any(String),
|
generatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user