Skip to content

Commit

Permalink
chore: Add tests for template registration handling, improve detectio…
Browse files Browse the repository at this point in the history
…n of incorrectly located templates
  • Loading branch information
t1mmen committed Jan 6, 2025
1 parent 06db56c commit 4febf28
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-bobcats-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@t1mmen/srtd": patch
---

Add tests for template registration handling, improve detection of incorrectly located templates
76 changes: 76 additions & 0 deletions src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
196 changes: 196 additions & 0 deletions src/utils/registerTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
6 changes: 6 additions & 0 deletions src/utils/registerTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 4febf28

Please sign in to comment.