- Project setup: package.json, tsconfig.json, jest.config.js, .env.example - Config: env.ts, constants.ts (ALLOWED_UNITS, bcrypt rounds, JWT, deletion windows) - DB: Knex connection, knexfile, migrations 001-006 (users, password_reset_tokens, pantry_items, recipes+recipe_ingredients, shopping_lists+items, deleted_records) - Seeds: 10 seeded recipes with full ingredient lists - Middleware: JWT authMiddleware (validates token + user existence), errorHandler - Utils: Pino logger, JWT sign helper, Zod validators for all request shapes - Services: authService (signup/signin/google/pwd-reset/soft-delete/restore), pantryService (CRUD + case-insensitive duplicate guard), recipeService (browse+filter+scale), shoppingListService (CRUD+merge logic), syncService (delta sync + tombstone cleanup), emailService (SendGrid) - Routes: /v1/auth, /v1/pantry, /v1/recipes, /v1/shopping-lists, /v1/sync - App: Express factory (createApp), server entry point - Jobs: node-cron daily hard-delete + tombstone cleanup - Tests: validators, utils, auth, pantry, recipes, shopping lists, sync
159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
import { signupSchema, signinSchema, addPantryItemSchema, updatePantryItemSchema, addShoppingListItemSchema, updateShoppingListItemSchema, addRecipesToListSchema, recipeQuerySchema, syncQuerySchema } from '../../utils/validators';
|
|
|
|
describe('Validators', () => {
|
|
describe('signupSchema', () => {
|
|
it('accepts valid signup data', () => {
|
|
const result = signupSchema.safeParse({ email: 'user@example.com', password: 'password123', name: 'Jane' });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid email', () => {
|
|
const result = signupSchema.safeParse({ email: 'not-an-email', password: 'password123', name: 'Jane' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects short password', () => {
|
|
const result = signupSchema.safeParse({ email: 'user@example.com', password: 'short', name: 'Jane' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects non-alphanumeric password', () => {
|
|
const result = signupSchema.safeParse({ email: 'user@example.com', password: 'pass!word', name: 'Jane' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects empty name', () => {
|
|
const result = signupSchema.safeParse({ email: 'user@example.com', password: 'password123', name: '' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('addPantryItemSchema', () => {
|
|
it('accepts valid pantry item', () => {
|
|
const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 5 });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects zero quantity', () => {
|
|
const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 0 });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects negative quantity', () => {
|
|
const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: -1 });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects fractional quantity', () => {
|
|
const result = addPantryItemSchema.safeParse({ item_name: 'Flour', quantity: 1.5 });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects empty item name', () => {
|
|
const result = addPantryItemSchema.safeParse({ item_name: '', quantity: 5 });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('updatePantryItemSchema', () => {
|
|
it('accepts valid update', () => {
|
|
const result = updatePantryItemSchema.safeParse({ quantity: 3 });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('accepts update with last_modified', () => {
|
|
const result = updatePantryItemSchema.safeParse({ quantity: 3, last_modified: '2024-01-15T10:30:00.000Z' });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('addShoppingListItemSchema', () => {
|
|
it('accepts valid item', () => {
|
|
const result = addShoppingListItemSchema.safeParse({ item_name: 'Milk', quantity: 2, unit: 'cups' });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid unit', () => {
|
|
const result = addShoppingListItemSchema.safeParse({ item_name: 'Milk', quantity: 2, unit: 'gallons' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('accepts fractional quantity', () => {
|
|
const result = addShoppingListItemSchema.safeParse({ item_name: 'Flour', quantity: 2.5, unit: 'cups' });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('updateShoppingListItemSchema', () => {
|
|
it('accepts partial update', () => {
|
|
const result = updateShoppingListItemSchema.safeParse({ checked_off: true });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects empty object', () => {
|
|
const result = updateShoppingListItemSchema.safeParse({});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('addRecipesToListSchema', () => {
|
|
it('accepts valid recipe ids', () => {
|
|
const result = addRecipesToListSchema.safeParse({
|
|
recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'],
|
|
scale_factor: 2,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects empty recipe_ids', () => {
|
|
const result = addRecipesToListSchema.safeParse({ recipe_ids: [] });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('rejects scale_factor > 3', () => {
|
|
const result = addRecipesToListSchema.safeParse({
|
|
recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'],
|
|
scale_factor: 4,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
it('defaults scale_factor to 1', () => {
|
|
const result = addRecipesToListSchema.safeParse({
|
|
recipe_ids: ['123e4567-e89b-12d3-a456-426614174000'],
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) expect(result.data.scale_factor).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('recipeQuerySchema', () => {
|
|
it('defaults to all filter, page 1, limit 20', () => {
|
|
const result = recipeQuerySchema.safeParse({});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.filter).toBe('all');
|
|
expect(result.data.page).toBe(1);
|
|
expect(result.data.limit).toBe(20);
|
|
}
|
|
});
|
|
|
|
it('rejects limit > 50', () => {
|
|
const result = recipeQuerySchema.safeParse({ limit: '51' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('syncQuerySchema', () => {
|
|
it('accepts valid ISO timestamp', () => {
|
|
const result = syncQuerySchema.safeParse({ since: '2024-01-15T10:30:00.000Z' });
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid timestamp', () => {
|
|
const result = syncQuerySchema.safeParse({ since: 'not-a-date' });
|
|
expect(result.success).toBe(false);
|
|
});
|
|
});
|
|
});
|