Scaffold project and implement config, DB schema/queries

- Set up package.json (ESM, scripts), tsconfig.json, vitest.config.ts
- Install runtime and dev dependencies
- Add CLAUDE.md with architecture notes and code quality rules
- Config module with env var parsing and JWT_SECRET validation
- DB schema: users + files tables with FK cascade
- DB queries: createUser, getUserBy*, createFile, getFileById, getFilesByUserId, deleteFile
- Tests for config, db/users, db/files (15 tests passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:48:21 -08:00
parent 5902cc404a
commit b6aa6211a9
14 changed files with 3591 additions and 3 deletions

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword } from '../../src/services/auth.ts';
describe('services/auth - password hashing', () => {
it('hashes a password and verifies it', async () => {
const hash = await hashPassword('mysecret');
expect(hash).not.toBe('mysecret');
await expect(verifyPassword('mysecret', hash)).resolves.toBe(true);
});
it('rejects wrong password', async () => {
const hash = await hashPassword('mysecret');
await expect(verifyPassword('wrong', hash)).resolves.toBe(false);
});
it('produces different hashes for same password', async () => {
const h1 = await hashPassword('same');
const h2 = await hashPassword('same');
expect(h1).not.toBe(h2);
});
});

68
tests/unit/config.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('config', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('returns defaults when env vars are not set', async () => {
process.env.JWT_SECRET = 'test-secret';
delete process.env.PORT;
delete process.env.HOST;
delete process.env.JWT_EXPIRY;
delete process.env.DB_PATH;
delete process.env.UPLOAD_DIR;
delete process.env.LOG_FILE;
delete process.env.MAX_FILE_SIZE;
delete process.env.BASE_URL;
delete process.env.COOKIE_SECURE;
delete process.env.TRUST_PROXY;
const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig();
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
expect(config.jwtExpiry).toBe('7d');
expect(config.dbPath).toBe('./data/nanodrop.db');
expect(config.uploadDir).toBe('./data/uploads');
expect(config.logFile).toBe('./data/nanodrop.log');
expect(config.maxFileSize).toBe(104857600);
expect(config.baseUrl).toBe('http://localhost:3000');
expect(config.cookieSecure).toBe(false);
expect(config.trustProxy).toBe(false);
});
it('reads values from env vars', async () => {
process.env.JWT_SECRET = 'my-secret';
process.env.PORT = '4000';
process.env.HOST = '127.0.0.1';
process.env.JWT_EXPIRY = '1d';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = 'true';
process.env.MAX_FILE_SIZE = '52428800';
const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig();
expect(config.port).toBe(4000);
expect(config.host).toBe('127.0.0.1');
expect(config.jwtSecret).toBe('my-secret');
expect(config.jwtExpiry).toBe('1d');
expect(config.cookieSecure).toBe(true);
expect(config.trustProxy).toBe(true);
expect(config.maxFileSize).toBe(52428800);
});
it('throws when JWT_SECRET is missing', async () => {
delete process.env.JWT_SECRET;
const { loadConfig } = await import('../../src/config.ts');
expect(() => loadConfig()).toThrow('JWT_SECRET');
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { initDb } from '../../src/db/schema.ts';
import { createUser } from '../../src/db/users.ts';
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../../src/db/files.ts';
import type Database from 'better-sqlite3';
describe('db/files', () => {
let db: Database.Database;
let userId: number;
beforeEach(() => {
db = initDb(':memory:');
const user = createUser(db, { username: 'alice', passwordHash: 'hash' });
userId = user.id;
});
function makeFile(overrides: Partial<Parameters<typeof createFile>[1]> = {}) {
return createFile(db, {
id: 'file1',
userId,
originalName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 1024,
storedName: 'file1.jpg',
...overrides,
});
}
it('creates a file and retrieves by id', () => {
const file = makeFile();
expect(file.id).toBe('file1');
expect(file.user_id).toBe(userId);
expect(file.original_name).toBe('photo.jpg');
expect(file.mime_type).toBe('image/jpeg');
expect(file.size).toBe(1024);
expect(file.stored_name).toBe('file1.jpg');
const found = getFileById(db, 'file1');
expect(found).toEqual(file);
});
it('returns undefined for unknown file id', () => {
expect(getFileById(db, 'nope')).toBeUndefined();
});
it('lists files by user ordered newest first', () => {
makeFile({ id: 'a', storedName: 'a.jpg' });
makeFile({ id: 'b', storedName: 'b.jpg' });
const files = getFilesByUserId(db, userId);
expect(files).toHaveLength(2);
expect(files.map((f) => f.id)).toContain('a');
});
it('returns empty array for user with no files', () => {
expect(getFilesByUserId(db, userId)).toEqual([]);
});
it('deletes a file by id and userId', () => {
makeFile();
const deleted = deleteFile(db, 'file1', userId);
expect(deleted).toBe(true);
expect(getFileById(db, 'file1')).toBeUndefined();
});
it('returns false when deleting a file belonging to another user', () => {
makeFile();
const deleted = deleteFile(db, 'file1', 999);
expect(deleted).toBe(false);
expect(getFileById(db, 'file1')).toBeDefined();
});
it('cascades delete when user is deleted', () => {
makeFile();
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
expect(getFileById(db, 'file1')).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { initDb } from '../../src/db/schema.ts';
import { createUser, getUserByUsername, getUserById } from '../../src/db/users.ts';
import type Database from 'better-sqlite3';
describe('db/users', () => {
let db: Database.Database;
beforeEach(() => {
db = initDb(':memory:');
});
it('creates a user and retrieves by username', () => {
const user = createUser(db, { username: 'alice', passwordHash: 'hash123' });
expect(user.id).toBeTypeOf('number');
expect(user.username).toBe('alice');
expect(user.password_hash).toBe('hash123');
expect(user.created_at).toBeTruthy();
const found = getUserByUsername(db, 'alice');
expect(found).toEqual(user);
});
it('returns undefined for unknown username', () => {
expect(getUserByUsername(db, 'ghost')).toBeUndefined();
});
it('retrieves user by id', () => {
const user = createUser(db, { username: 'bob', passwordHash: 'hash456' });
const found = getUserById(db, user.id);
expect(found).toEqual(user);
});
it('returns undefined for unknown id', () => {
expect(getUserById(db, 999)).toBeUndefined();
});
it('enforces unique username constraint', () => {
createUser(db, { username: 'carol', passwordHash: 'hash' });
expect(() => createUser(db, { username: 'carol', passwordHash: 'other' })).toThrow();
});
});