From 04fabf115ccea31ac504b06cc51d78423b697f60 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 28 Jul 2026 15:07:57 +0200 Subject: refactor: services layer, valibot validation, shared CSS utilities, aria labels, and drizzle migrate - Extract groceries/categories/references services with db injection - Add valibot for form validation and safe numeric id parsing - Fix category delete FK behavior with onDelete: set null + migration - Replace custom migrate.js with drizzle-orm/libsql/migrator - Add shared CSS utility classes in app.css (forms, lists, buttons) - Add aria-pressed and aria-label on interactive elements - Remove redundant app.css imports and unused /stores import - Add integration tests for all services using drizzle migrate helper - Remove smoke test; add db:migrate and db:studio scripts --- src/lib/server/services/categories.test.ts | 51 ++++++++++++ src/lib/server/services/categories.ts | 21 +++++ src/lib/server/services/groceries.test.ts | 98 +++++++++++++++++++++++ src/lib/server/services/groceries.ts | 122 +++++++++++++++++++++++++++++ src/lib/server/services/references.test.ts | 70 +++++++++++++++++ src/lib/server/services/references.ts | 38 +++++++++ src/lib/server/services/types.ts | 6 ++ src/lib/server/services/validation.ts | 45 +++++++++++ 8 files changed, 451 insertions(+) create mode 100644 src/lib/server/services/categories.test.ts create mode 100644 src/lib/server/services/categories.ts create mode 100644 src/lib/server/services/groceries.test.ts create mode 100644 src/lib/server/services/groceries.ts create mode 100644 src/lib/server/services/references.test.ts create mode 100644 src/lib/server/services/references.ts create mode 100644 src/lib/server/services/types.ts create mode 100644 src/lib/server/services/validation.ts (limited to 'src/lib/server/services') diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts new file mode 100644 index 0000000..cfde1da --- /dev/null +++ b/src/lib/server/services/categories.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { loadAll, add, deleteCategory } from './categories'; + +describe('categories service', () => { + let db: Awaited>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadAll returns categories sorted by name', async () => { + await db.insert(categories).values([{ name: 'Zebra' }, { name: 'Apple' }]); + + const result = await loadAll(db); + expect(result.map((c) => c.name)).toEqual(['Apple', 'Zebra']); + }); + + it('add creates a category', async () => { + const cat = await add('Beverages', db); + expect(cat.name).toBe('Beverages'); + + const rows = await db.select().from(categories); + expect(rows).toHaveLength(1); + }); + + it('deleteCategory removes the category and nullifies grocery categoryId', async () => { + const [cat] = await db + .insert(categories) + .values({ name: 'Dairy' }) + .returning(); + const now = new Date().toISOString(); + await db.insert(groceries).values({ + name: 'milk', + checked: false, + createdAt: now, + categoryId: cat.id, + }); + + await deleteCategory(cat.id, db); + + const catRows = await db.select().from(categories); + expect(catRows).toHaveLength(0); + + const [item] = await db.select().from(groceries); + expect(item.categoryId).toBeNull(); + }); +}); diff --git a/src/lib/server/services/categories.ts b/src/lib/server/services/categories.ts new file mode 100644 index 0000000..f8b420f --- /dev/null +++ b/src/lib/server/services/categories.ts @@ -0,0 +1,21 @@ +import { db as defaultDb } from '$lib/server/db'; +import { categories } from '$lib/server/db/schema'; +import { eq, asc } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Category } from './types'; + +export async function loadAll(db = defaultDb): Promise { + return db.select().from(categories).orderBy(asc(categories.name)); +} + +export async function add(name: string, db = defaultDb): Promise { + const [inserted] = await db.insert(categories).values({ name }).returning(); + return inserted; +} + +export async function deleteCategory( + id: number, + db = defaultDb, +): Promise { + await db.delete(categories).where(eq(categories.id, id)); +} diff --git a/src/lib/server/services/groceries.test.ts b/src/lib/server/services/groceries.test.ts new file mode 100644 index 0000000..83f8d95 --- /dev/null +++ b/src/lib/server/services/groceries.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories, references } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { + loadUnchecked, + loadAll, + toggle, + addOrUncheck, + deleteGrocery, +} from './groceries'; + +describe('groceries service', () => { + let db: Awaited>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadUnchecked returns only unchecked items', async () => { + const now = new Date().toISOString(); + await db.insert(groceries).values([ + { name: 'milk', checked: false, createdAt: now }, + { name: 'bread', checked: true, createdAt: now }, + ]); + + const result = await loadUnchecked(db); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('milk'); + }); + + it('loadAll returns all items', async () => { + const now = new Date().toISOString(); + await db.insert(groceries).values([ + { name: 'milk', checked: false, createdAt: now }, + { name: 'bread', checked: true, createdAt: now }, + ]); + + const result = await loadAll(db); + expect(result.items).toHaveLength(2); + }); + + it('toggle flips checked state', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: false, createdAt: now }); + const [item] = await db.select().from(groceries); + + await toggle(item.id, db); + + const [updated] = await db + .select() + .from(groceries) + .where(eq(groceries.id, item.id)); + expect(updated.checked).toBe(true); + }); + + it('addOrUncheck creates a new grocery', async () => { + const { item, created } = await addOrUncheck('eggs', null, db); + + expect(created).toBe(true); + expect(item.name).toBe('eggs'); + + const rows = await db.select().from(groceries); + expect(rows).toHaveLength(1); + }); + + it('addOrUncheck unchecks an existing checked item', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: true, createdAt: now }); + + const { item, created } = await addOrUncheck('milk', null, db); + + expect(created).toBe(false); + expect(item.checked).toBe(false); + + const rows = await db.select().from(groceries); + expect(rows).toHaveLength(1); + expect(rows[0].checked).toBe(false); + }); + + it('deleteGrocery removes the item', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: false, createdAt: now }); + const [item] = await db.select().from(groceries); + + await deleteGrocery(item.id, db); + + const rows = await db.select().from(groceries); + expect(rows).toHaveLength(0); + }); +}); diff --git a/src/lib/server/services/groceries.ts b/src/lib/server/services/groceries.ts new file mode 100644 index 0000000..fb42224 --- /dev/null +++ b/src/lib/server/services/groceries.ts @@ -0,0 +1,122 @@ +import { db as defaultDb } from '$lib/server/db'; +import { groceries, categories, references } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import type * as schema from '$lib/server/db/schema'; +import type { Category, Grocery, Reference } from './types'; + +export type AppDatabase = LibSQLDatabase; + +export interface EnrichedGrocery extends Grocery { + categoryName: string | null; + bestRef: Reference | null; +} + +export async function loadUnchecked( + db = defaultDb, +): Promise<{ items: EnrichedGrocery[]; categories: Category[] }> { + const [items, cats, refs] = await Promise.all([ + db + .select() + .from(groceries) + .where(eq(groceries.checked, false)) + .orderBy(desc(groceries.createdAt)), + db.select().from(categories).orderBy(desc(categories.name)), + db.select().from(references).orderBy(desc(references.qualityScore)), + ]); + + return { + items: enrichItems(items, cats, refs), + categories: cats, + }; +} + +export async function loadAll( + db = defaultDb, +): Promise<{ items: EnrichedGrocery[]; categories: Category[] }> { + const [items, cats, refs] = await Promise.all([ + db.select().from(groceries).orderBy(desc(groceries.createdAt)), + db.select().from(categories).orderBy(desc(categories.name)), + db.select().from(references).orderBy(desc(references.qualityScore)), + ]); + + return { + items: enrichItems(items, cats, refs), + categories: cats, + }; +} + +export async function toggle(id: number, db = defaultDb): Promise { + const [item] = await db.select().from(groceries).where(eq(groceries.id, id)); + if (!item) throw new Error('Grocery not found'); + + await db + .update(groceries) + .set({ checked: !item.checked }) + .where(eq(groceries.id, id)); +} + +export async function addOrUncheck( + name: string, + categoryId: number | null, + db = defaultDb, +): Promise<{ item: Grocery; created: boolean }> { + const [existing] = await db + .select() + .from(groceries) + .where(eq(groceries.name, name)) + .limit(1); + + if (existing) { + await db + .update(groceries) + .set({ + checked: false, + categoryId: categoryId ?? existing.categoryId, + }) + .where(eq(groceries.id, existing.id)); + return { + item: { + ...existing, + checked: false, + categoryId: categoryId ?? existing.categoryId, + }, + created: false, + }; + } + + const [inserted] = await db + .insert(groceries) + .values({ + name, + categoryId, + createdAt: new Date().toISOString(), + }) + .returning(); + + return { item: inserted, created: true }; +} + +export async function deleteGrocery(id: number, db = defaultDb): Promise { + await db.delete(groceries).where(eq(groceries.id, id)); +} + +function enrichItems( + rawItems: Grocery[], + rawCategories: Category[], + rawReferences: Reference[], +): EnrichedGrocery[] { + const catMap = new Map(rawCategories.map((c) => [c.id, c.name])); + const bestRef = new Map(); + for (const ref of rawReferences) { + if (!bestRef.has(ref.groceryId)) { + bestRef.set(ref.groceryId, ref); + } + } + + return rawItems.map((i) => ({ + ...i, + categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, + bestRef: bestRef.get(i.id) ?? null, + })); +} diff --git a/src/lib/server/services/references.test.ts b/src/lib/server/services/references.test.ts new file mode 100644 index 0000000..c02f9e6 --- /dev/null +++ b/src/lib/server/services/references.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, references } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { loadForGrocery, add, deleteReference } from './references'; + +describe('references service', () => { + let db: Awaited>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadForGrocery returns refs for the given grocery', async () => { + const now = new Date().toISOString(); + const [g1] = await db + .insert(groceries) + .values({ name: 'coffee', checked: false, createdAt: now }) + .returning(); + const [g2] = await db + .insert(groceries) + .values({ name: 'tea', checked: false, createdAt: now }) + .returning(); + + await db.insert(references).values([ + { groceryId: g1.id, reference: 'Brand A' }, + { groceryId: g2.id, reference: 'Brand B' }, + ]); + + const refs = await loadForGrocery(g1.id, db); + expect(refs).toHaveLength(1); + expect(refs[0].reference).toBe('Brand A'); + }); + + it('add creates a reference', async () => { + const now = new Date().toISOString(); + const [g] = await db + .insert(groceries) + .values({ name: 'pasta', checked: false, createdAt: now }) + .returning(); + + const ref = await add(g.id, 'Barilla', 2.5, '€/kg', 85, db); + expect(ref.reference).toBe('Barilla'); + expect(ref.price).toBe(2.5); + + const rows = await db + .select() + .from(references) + .where(eq(references.groceryId, g.id)); + expect(rows).toHaveLength(1); + }); + + it('deleteReference removes the ref', async () => { + const now = new Date().toISOString(); + const [g] = await db + .insert(groceries) + .values({ name: 'rice', checked: false, createdAt: now }) + .returning(); + const [ref] = await db + .insert(references) + .values({ groceryId: g.id, reference: 'Brand X' }) + .returning(); + + await deleteReference(ref.id, db); + + const rows = await db.select().from(references); + expect(rows).toHaveLength(0); + }); +}); diff --git a/src/lib/server/services/references.ts b/src/lib/server/services/references.ts new file mode 100644 index 0000000..99ba487 --- /dev/null +++ b/src/lib/server/services/references.ts @@ -0,0 +1,38 @@ +import { db as defaultDb } from '$lib/server/db'; +import { references } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Reference } from './types'; + +export async function loadForGrocery( + groceryId: number, + db = defaultDb, +): Promise { + return db + .select() + .from(references) + .where(eq(references.groceryId, groceryId)) + .orderBy(desc(references.qualityScore)); +} + +export async function add( + groceryId: number, + reference: string, + price: number | null, + unit: string | null, + qualityScore: number | null, + db = defaultDb, +): Promise { + const [inserted] = await db + .insert(references) + .values({ groceryId, reference, price, unit, qualityScore }) + .returning(); + return inserted; +} + +export async function deleteReference( + id: number, + db = defaultDb, +): Promise { + await db.delete(references).where(eq(references.id, id)); +} diff --git a/src/lib/server/services/types.ts b/src/lib/server/services/types.ts new file mode 100644 index 0000000..e5e3918 --- /dev/null +++ b/src/lib/server/services/types.ts @@ -0,0 +1,6 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import type { categories, groceries, references } from '$lib/server/db/schema'; + +export type Category = InferSelectModel; +export type Grocery = InferSelectModel; +export type Reference = InferSelectModel; diff --git a/src/lib/server/services/validation.ts b/src/lib/server/services/validation.ts new file mode 100644 index 0000000..a8cebd9 --- /dev/null +++ b/src/lib/server/services/validation.ts @@ -0,0 +1,45 @@ +import * as v from 'valibot'; + +export const IdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); + +export const AddGrocerySchema = v.object({ + name: v.pipe(v.string(), v.minLength(1)), + categoryId: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(1))), +}); + +export const AddReferenceSchema = v.object({ + reference: v.pipe(v.string(), v.minLength(1)), + price: v.nullable(v.pipe(v.number(), v.minValue(0))), + unit: v.nullable(v.string()), + qualityScore: v.nullable( + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(100)), + ), +}); + +export function numericId( + value: FormDataEntryValue | null | undefined, +): number { + const str = value?.toString().trim() ?? ''; + const n = Number(str); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) { + throw new Error('Invalid id'); + } + return n; +} + +export function parseFormString( + value: FormDataEntryValue | null | undefined, +): string | null { + const str = value?.toString().trim() ?? ''; + return str || null; +} + +export function parseOptionalNumber( + value: FormDataEntryValue | null | undefined, +): number | null { + const str = value?.toString().trim() ?? ''; + if (!str) return null; + const n = Number(str); + if (!Number.isFinite(n)) return null; + return n; +} -- cgit v1.3