From 4febf28294a5c8a7fe718cc5d79f7a9f5646e405 Mon Sep 17 00:00:00 2001 From: Timm Stokke Date: Mon, 6 Jan 2025 14:18:42 +0100 Subject: [PATCH] chore: Add tests for template registration handling, improve detection of incorrectly located templates --- .changeset/cyan-bobcats-invite.md | 5 + src/__tests__/helpers.ts | 76 +++++++++++ src/utils/registerTemplate.test.ts | 196 +++++++++++++++++++++++++++++ src/utils/registerTemplate.ts | 6 + 4 files changed, 283 insertions(+) create mode 100644 .changeset/cyan-bobcats-invite.md create mode 100644 src/__tests__/helpers.ts create mode 100644 src/utils/registerTemplate.test.ts diff --git a/.changeset/cyan-bobcats-invite.md b/.changeset/cyan-bobcats-invite.md new file mode 100644 index 0000000..634f5d6 --- /dev/null +++ b/.changeset/cyan-bobcats-invite.md @@ -0,0 +1,5 @@ +--- +"@t1mmen/srtd": patch +--- + +Add tests for template registration handling, improve detection of incorrectly located templates diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 0000000..17afd0a --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,76 @@ +// src/__tests__/helpers.ts +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { TEST_FN_PREFIX } from './vitest.setup.js'; + +interface TestContext { + timestamp: number; + testDir: string; + testFunctionName: string; + templateCounter: number; +} + +/** + * Creates a test context with all the goodies we need! ๐ŸŽ + */ +export function createTestContext(name = 'test'): TestContext { + return { + timestamp: Date.now(), + testDir: path.join(process.env.TMPDIR || '/tmp', `srtd-${name}-${Date.now()}`), + testFunctionName: `${TEST_FN_PREFIX}${Date.now()}`, + templateCounter: 0, + }; +} + +/** + * Get unique template names - perfect for testing! ๐Ÿท๏ธ + */ +export function getNextTemplateName(context: TestContext, prefix = 'template') { + context.templateCounter++; + return `${prefix}_${context.timestamp}_${context.templateCounter}`; +} + +/** + * Create a template file with whatever content you want! ๐Ÿ“ + */ +export async function createTemplate( + context: TestContext, + name: string, + content: string, + dir?: string +) { + const fullPath = dir + ? path.join(context.testDir, 'test-templates', dir, name) + : path.join(context.testDir, 'test-templates', name); + + try { + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + return fullPath; + } catch (error) { + console.error('Error creating template:', error); + throw error; + } +} + +/** + * Create a template with a basic Postgres function - the bread and butter of testing! ๐Ÿž + */ +export async function createTemplateWithFunc( + context: TestContext, + prefix: string, + funcSuffix = '', + dir?: string +) { + const name = `${getNextTemplateName(context, prefix)}.sql`; + const funcName = `${context.testFunctionName}${funcSuffix}`; + const content = `CREATE OR REPLACE FUNCTION ${funcName}() RETURNS void AS $$ BEGIN NULL; END; $$ LANGUAGE plpgsql;`; + return createTemplate(context, name, content, dir); +} + +/** + * Clean up after our tests like good citizens! ๐Ÿงน + */ +export async function cleanupTestContext(context: TestContext) { + await fs.rm(context.testDir, { recursive: true, force: true }); +} diff --git a/src/utils/registerTemplate.test.ts b/src/utils/registerTemplate.test.ts new file mode 100644 index 0000000..db0d9e1 --- /dev/null +++ b/src/utils/registerTemplate.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { TEST_ROOT } from '../__tests__/vitest.setup.js'; +import { calculateMD5 } from '../utils/calculateMD5.js'; +import { getConfig } from '../utils/config.js'; +import { registerTemplate } from '../utils/registerTemplate.js'; + +describe('registerTemplate', () => { + const testContext = { + testId: 0, + testDir: '', + templateCounter: 0, + }; + + beforeEach(async () => { + testContext.testId = Math.floor(Math.random() * 1000000); + testContext.testDir = path.join(TEST_ROOT, `register-template-${testContext.testId}`); + testContext.templateCounter = 0; + + // Create test directories using config paths + const config = await getConfig(testContext.testDir); + await fs.mkdir(path.join(testContext.testDir, config.templateDir), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testContext.testDir, { recursive: true, force: true }); + }); + + const getNextTemplateName = (prefix = 'template') => { + testContext.templateCounter++; + return `${prefix}_${testContext.testId}_${testContext.templateCounter}`; + }; + + const createTemplate = async (name: string, content: string, dir?: string) => { + const config = await getConfig(testContext.testDir); + const fullPath = dir + ? path.join(testContext.testDir, config.templateDir, dir, name) + : path.join(testContext.testDir, config.templateDir, name); + try { + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + return fullPath; + } catch (error) { + console.error('Error creating template:', error); + throw error; + } + }; + + it('successfully registers a valid template ๐ŸŽฏ', async () => { + const templateName = getNextTemplateName('success'); + const templateContent = ` + CREATE FUNCTION test() + RETURNS void AS $$ + BEGIN + NULL; + END; + $$ LANGUAGE plpgsql; + `; + const templatePath = await createTemplate(`${templateName}.sql`, templateContent); + const config = await getConfig(testContext.testDir); + + await registerTemplate(templatePath, testContext.testDir); + + const buildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + const relPath = path.relative(testContext.testDir, templatePath); + + expect(buildLog.templates[relPath]).toBeDefined(); + expect(buildLog.templates[relPath].lastBuildHash).toBe(await calculateMD5(templateContent)); + expect(buildLog.templates[relPath].lastBuildDate).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ); + expect(buildLog.templates[relPath].lastMigrationFile).toBe(''); + }); + + it('prevents registering templates outside templateDir ๐Ÿ”’', async () => { + // Try to register a template from outside the configured template directory + const outsideContent = 'SELECT 1;'; + const outsidePath = path.join(testContext.testDir, 'outside.sql'); + await fs.writeFile(outsidePath, outsideContent); + + await expect(registerTemplate(outsidePath, testContext.testDir)).rejects.toThrow( + /Template in wrong directly/ + ); + + // Also try with a path that looks like it's in templateDir but isn't + const sneakyPath = path.join(testContext.testDir, 'fake-test-templates', 'sneaky.sql'); + await fs.mkdir(path.dirname(sneakyPath), { recursive: true }); + await fs.writeFile(sneakyPath, outsideContent); + + await expect(registerTemplate(sneakyPath, testContext.testDir)).rejects.toThrow( + /Template in wrong directly/ + ); + }); + + it('handles templates in nested directories ๐Ÿ“‚', async () => { + const templateName = getNextTemplateName('nested'); + const templatePath = await createTemplate( + `${templateName}.sql`, + 'SELECT 1;', + 'deep/nested/dir' + ); + + await registerTemplate(templatePath, testContext.testDir); + + const config = await getConfig(testContext.testDir); + const buildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + const relPath = path.relative(testContext.testDir, templatePath); + + expect(buildLog.templates[relPath]).toBeDefined(); + }); + + it('updates existing template registration ๐Ÿ”„', async () => { + const templateName = getNextTemplateName('update'); + const templatePath = await createTemplate(`${templateName}.sql`, 'SELECT 1;'); + const config = await getConfig(testContext.testDir); + + await registerTemplate(templatePath, testContext.testDir); + + const initialBuildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + const relPath = path.relative(testContext.testDir, templatePath); + const initialHash = initialBuildLog.templates[relPath].lastBuildHash; + + const newContent = 'SELECT 2;'; + await fs.writeFile(templatePath, newContent); + await registerTemplate(templatePath, testContext.testDir); + + const updatedBuildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + expect(updatedBuildLog.templates[relPath].lastBuildHash).toBe(await calculateMD5(newContent)); + expect(updatedBuildLog.templates[relPath].lastBuildHash).not.toBe(initialHash); + }); + + it('handles empty template files ๐Ÿ“„', async () => { + const templateName = getNextTemplateName('empty'); + const templatePath = await createTemplate(`${templateName}.sql`, ''); + + await registerTemplate(templatePath, testContext.testDir); + + const config = await getConfig(testContext.testDir); + const buildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + const relPath = path.relative(testContext.testDir, templatePath); + expect(buildLog.templates[relPath].lastBuildHash).toBe(await calculateMD5('')); + }); + + it('handles large template files efficiently ๐Ÿ“š', async () => { + const templateName = getNextTemplateName('large'); + const largeContent = ` + SELECT ${`'x'`.repeat(100 * 1024)}; + `; + const templatePath = await createTemplate(`${templateName}.sql`, largeContent); + + const startTime = Date.now(); + await registerTemplate(templatePath, testContext.testDir); + const duration = Date.now() - startTime; + + expect(duration).toBeLessThan(1000); + + const config = await getConfig(testContext.testDir); + const buildLog = JSON.parse( + await fs.readFile(path.join(testContext.testDir, config.buildLog), 'utf-8') + ); + const relPath = path.relative(testContext.testDir, templatePath); + expect(buildLog.templates[relPath].lastBuildHash).toBe(await calculateMD5(largeContent)); + }); + + it('gracefully handles non-existent templates ๐Ÿšซ', async () => { + const config = await getConfig(testContext.testDir); + const nonExistentPath = path.join(testContext.testDir, config.templateDir, 'nope.sql'); + await expect(registerTemplate(nonExistentPath, testContext.testDir)).rejects.toThrow( + /Template.*not found/ + ); + }); + + it('fails gracefully with filesystem errors ๐Ÿ’ฅ', async () => { + const templateName = getNextTemplateName('permission'); + const templatePath = await createTemplate(`${templateName}.sql`, 'SELECT 1;', 'locked'); + const templateDir = path.dirname(templatePath); + + try { + await fs.chmod(templateDir, 0o000); + await expect(registerTemplate(templatePath, testContext.testDir)).rejects.toThrow(); + } finally { + await fs.chmod(templateDir, 0o755); + } + }); +}); diff --git a/src/utils/registerTemplate.ts b/src/utils/registerTemplate.ts index 1314146..a82af5a 100644 --- a/src/utils/registerTemplate.ts +++ b/src/utils/registerTemplate.ts @@ -31,6 +31,12 @@ export async function registerTemplate(templatePath: string, baseDir: string): P throw new Error(`Template ${templatePath} not found`); } + if (!resolvedPath.startsWith(path.resolve(baseDir, config.templateDir))) { + throw new Error( + `Template in wrong directly, must be located inside of configured templateDir: ${config.templateDir}/*` + ); + } + const content = await fs.readFile(resolvedPath, 'utf-8'); const hash = await calculateMD5(content); const relativePath = path.relative(baseDir, resolvedPath);