mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
af0eaceb98
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
/**
|
|
* Tests for Path Template Engine Utility
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
substituteTemplate,
|
|
validateTemplate,
|
|
generateMockPreviews,
|
|
getValidVariables,
|
|
type TemplateVariables
|
|
} from '@/lib/utils/path-template.util';
|
|
|
|
describe('substituteTemplate', () => {
|
|
it('should substitute all valid variables', () => {
|
|
const template = '{author}/{title}/{narrator}/{asin}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Brandon Sanderson',
|
|
title: 'Mistborn',
|
|
narrator: 'Michael Kramer',
|
|
asin: 'B002UZMLXM'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Brandon Sanderson/Mistborn/Michael Kramer/B002UZMLXM');
|
|
});
|
|
|
|
it('should handle missing optional variables gracefully', () => {
|
|
const template = '{author}/{title}/{narrator}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Andy Weir',
|
|
title: 'Project Hail Mary'
|
|
// narrator is missing
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Andy Weir/Project Hail Mary');
|
|
});
|
|
|
|
it('should sanitize invalid characters in values', () => {
|
|
const template = '{author}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author: <Test>',
|
|
title: 'Title|With*Invalid?Chars"'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).not.toContain('<');
|
|
expect(result).not.toContain('>');
|
|
expect(result).not.toContain(':');
|
|
expect(result).not.toContain('|');
|
|
expect(result).not.toContain('*');
|
|
expect(result).not.toContain('?');
|
|
expect(result).not.toContain('"');
|
|
});
|
|
|
|
it('should remove multiple consecutive spaces', () => {
|
|
const template = '{author}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author With Spaces',
|
|
title: 'Title With Spaces'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author With Spaces/Title With Spaces');
|
|
});
|
|
|
|
it('should handle empty string values', () => {
|
|
const template = '{author}/{title}/{narrator}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title',
|
|
narrator: ''
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/Title');
|
|
});
|
|
|
|
it('should remove leading and trailing slashes', () => {
|
|
const template = '/{author}/{title}/';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/Title');
|
|
});
|
|
|
|
it('should collapse multiple consecutive slashes', () => {
|
|
const template = '{author}//{title}///{narrator}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title',
|
|
narrator: 'Narrator'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/Title/Narrator');
|
|
});
|
|
|
|
it('should resolve escaped braces to literal brace characters', () => {
|
|
const template = '{author}/\\{{narrator}\\}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title',
|
|
narrator: 'Narrator'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/{Narrator}/Title');
|
|
});
|
|
|
|
it('should trim dots from path components', () => {
|
|
const template = '{author}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: '...Author...',
|
|
title: '..Title..'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result.startsWith('.')).toBe(false);
|
|
expect(result.endsWith('.')).toBe(false);
|
|
});
|
|
|
|
it('should limit path component length', () => {
|
|
const template = '{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'A'.repeat(300) // Very long title
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result.length).toBeLessThanOrEqual(200);
|
|
});
|
|
|
|
it('should handle static text in template', () => {
|
|
const template = 'Audiobooks/{author}/Books/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Audiobooks/Author/Books/Title');
|
|
});
|
|
|
|
it('should resolve escaped left brace only', () => {
|
|
const template = '{author}/\\{prefix {title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/{prefix Title');
|
|
});
|
|
|
|
it('should resolve escaped right brace only', () => {
|
|
const template = '{author}/{title} suffix\\}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/Title suffix}');
|
|
});
|
|
|
|
it('should resolve multiple escaped brace pairs', () => {
|
|
const template = '\\{{author}\\}/\\{{title}\\}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('{Author}/{Title}');
|
|
});
|
|
|
|
it('should handle escaped braces with missing optional variable', () => {
|
|
const template = '{author}/\\{{narrator}\\}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
// narrator is missing
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/{}/Title');
|
|
});
|
|
|
|
it('should handle escaped braces adjacent to path separators', () => {
|
|
const template = '{author}/\\{{narrator}\\}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title',
|
|
narrator: 'Michael Kramer'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/{Michael Kramer}/Title');
|
|
});
|
|
|
|
it('should handle escaped braces around static text', () => {
|
|
const template = '{author}/\\{narrated\\}/{title}';
|
|
const variables: TemplateVariables = {
|
|
author: 'Author',
|
|
title: 'Title'
|
|
};
|
|
|
|
const result = substituteTemplate(template, variables);
|
|
expect(result).toBe('Author/{narrated}/Title');
|
|
});
|
|
});
|
|
|
|
describe('validateTemplate', () => {
|
|
it('should accept valid templates', () => {
|
|
const templates = [
|
|
'{author}/{title}',
|
|
'{author}/{title}/{narrator}',
|
|
'Audiobooks/{author}/{title}',
|
|
'{author} - {title}',
|
|
'{author}/{title}/{asin}'
|
|
];
|
|
|
|
templates.forEach(template => {
|
|
const result = validateTemplate(template);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('should reject empty templates', () => {
|
|
const result = validateTemplate('');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('empty');
|
|
});
|
|
|
|
it('should reject whitespace-only templates', () => {
|
|
const result = validateTemplate(' ');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('empty');
|
|
});
|
|
|
|
it('should reject unknown variables', () => {
|
|
const result = validateTemplate('{author}/{invalid}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('Unknown variable');
|
|
expect(result.error).toContain('{invalid}');
|
|
});
|
|
|
|
it('should reject absolute paths with forward slash', () => {
|
|
const result = validateTemplate('/absolute/path/{author}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('absolute');
|
|
});
|
|
|
|
it('should reject absolute paths with drive letter', () => {
|
|
const result = validateTemplate('C:\\Users\\{author}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('absolute');
|
|
});
|
|
|
|
it('should reject invalid characters outside variables', () => {
|
|
const invalidChars = ['<', '>', ':', '"', '|', '?', '*'];
|
|
|
|
invalidChars.forEach(char => {
|
|
const result = validateTemplate(`{author}${char}{title}`);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('Invalid characters');
|
|
});
|
|
});
|
|
|
|
it('should reject backslashes that are not brace escapes', () => {
|
|
const result = validateTemplate('{author}\\n{title}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('forward slashes');
|
|
});
|
|
|
|
it('should accept templates without variables', () => {
|
|
const result = validateTemplate('Audiobooks/Default');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should provide helpful error messages for multiple unknown variables', () => {
|
|
const result = validateTemplate('{author}/{invalid1}/{invalid2}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('Unknown variable');
|
|
});
|
|
|
|
it('should list valid variables in error message', () => {
|
|
const result = validateTemplate('{invalid}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('{author}');
|
|
expect(result.error).toContain('{title}');
|
|
expect(result.error).toContain('{narrator}');
|
|
expect(result.error).toContain('{asin}');
|
|
});
|
|
|
|
it('should accept escaped braces around a variable', () => {
|
|
const result = validateTemplate('{author}/\\{{narrator}\\}/{title}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should accept escaped braces around static text', () => {
|
|
const result = validateTemplate('{author}/\\{custom\\}/{title}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should accept escaped left brace only', () => {
|
|
const result = validateTemplate('{author}/\\{prefix {title}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should accept escaped right brace only', () => {
|
|
const result = validateTemplate('{author}/{title} suffix\\}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should accept multiple escaped brace pairs', () => {
|
|
const result = validateTemplate('\\{{author}\\}/\\{{title}\\}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should accept backslash before brace but reject backslash before other characters', () => {
|
|
const result = validateTemplate('{author}\\n/\\{{title}\\}');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toContain('forward slashes');
|
|
});
|
|
|
|
it('should accept a template that is only escaped braces', () => {
|
|
const result = validateTemplate('\\{\\}');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('generateMockPreviews', () => {
|
|
it('should generate 3 preview examples', () => {
|
|
const template = '{author}/{title}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
expect(previews).toHaveLength(3);
|
|
});
|
|
|
|
it('should apply template correctly to all examples', () => {
|
|
const template = '{author}/{title}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
previews.forEach(preview => {
|
|
expect(preview).toContain('/');
|
|
expect(preview.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it('should include example without narrator', () => {
|
|
const template = '{author}/{title}/{narrator}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
// At least one preview should not have a third path component (no narrator)
|
|
const withoutNarrator = previews.some(preview => {
|
|
const parts = preview.split('/');
|
|
return parts.length === 2; // Only author and title
|
|
});
|
|
|
|
expect(withoutNarrator).toBe(true);
|
|
});
|
|
|
|
it('should handle templates with only static text', () => {
|
|
const template = 'Static/Path/Example';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
previews.forEach(preview => {
|
|
expect(preview).toBe('Static/Path/Example');
|
|
});
|
|
});
|
|
|
|
it('should sanitize mock data values', () => {
|
|
const template = '{author}/{title}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
previews.forEach(preview => {
|
|
expect(preview).not.toContain('<');
|
|
expect(preview).not.toContain('>');
|
|
expect(preview).not.toContain(':');
|
|
});
|
|
});
|
|
|
|
it('should include ASIN in examples when requested', () => {
|
|
const template = '{author}/{title}/{asin}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
// All examples should have ASIN (mock data includes it)
|
|
previews.forEach(preview => {
|
|
const parts = preview.split('/');
|
|
expect(parts.length).toBe(3);
|
|
expect(parts[2]).toMatch(/^B[A-Z0-9]+$/); // ASIN format
|
|
});
|
|
});
|
|
|
|
it('should handle complex templates with static text', () => {
|
|
const template = 'Library/{author}/Books/{title} - {asin}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
previews.forEach(preview => {
|
|
expect(preview).toContain('Library/');
|
|
expect(preview).toContain('/Books/');
|
|
expect(preview).toContain(' - B');
|
|
});
|
|
});
|
|
|
|
it('should resolve escaped braces in previews', () => {
|
|
const template = '{author}/\\{{narrator}\\}/{title}';
|
|
const previews = generateMockPreviews(template);
|
|
|
|
// First two mock entries have narrators
|
|
expect(previews[0]).toContain('{Michael Kramer}');
|
|
expect(previews[1]).toContain('{Stephen Fry}');
|
|
// Third mock entry has no narrator - escaped braces remain empty
|
|
expect(previews[2]).toContain('{}');
|
|
});
|
|
});
|
|
|
|
describe('getValidVariables', () => {
|
|
it('should return all valid variable names', () => {
|
|
const variables = getValidVariables();
|
|
|
|
expect(variables).toContain('author');
|
|
expect(variables).toContain('title');
|
|
expect(variables).toContain('narrator');
|
|
expect(variables).toContain('asin');
|
|
expect(variables).toContain('year');
|
|
expect(variables).toContain('series');
|
|
expect(variables).toContain('seriesPart');
|
|
expect(variables).toHaveLength(7);
|
|
});
|
|
|
|
it('should return a new array each time (not mutate original)', () => {
|
|
const vars1 = getValidVariables();
|
|
const vars2 = getValidVariables();
|
|
|
|
expect(vars1).toEqual(vars2);
|
|
expect(vars1).not.toBe(vars2); // Different array instances
|
|
});
|
|
});
|