mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
3a9ae4a439
Implements admin approval workflow for user requests with global and per-user auto-approve controls. Adds new request statuses ('awaiting_approval', 'denied'), related API endpoints, and UI for pending approvals. Introduces configurable audiobook organization path template with validation and preview in settings, updates database schema and migrations for new fields.
330 lines
10 KiB
TypeScript
330 lines
10 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 handle mixed forward and backward 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 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');
|
|
});
|
|
});
|
|
|
|
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 in template', () => {
|
|
const result = validateTemplate('{author}\\{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}');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
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).toHaveLength(5);
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|