aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-07-28 15:07:57 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-07-28 15:07:57 +0200
commit04fabf115ccea31ac504b06cc51d78423b697f60 (patch)
tree26c21f8822ccd24fce3a80b7361382c8a87d0c96 /src/lib
parent45cc0afb734a422ee7765dc5c60dccbcc6721367 (diff)
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
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/components/Nav.svelte3
-rw-r--r--src/lib/server/db/groceries.test.ts21
-rw-r--r--src/lib/server/db/ondelete.test.ts32
-rw-r--r--src/lib/server/db/schema.ts4
-rw-r--r--src/lib/server/db/test-helper.ts13
-rw-r--r--src/lib/server/services/categories.test.ts51
-rw-r--r--src/lib/server/services/categories.ts21
-rw-r--r--src/lib/server/services/groceries.test.ts98
-rw-r--r--src/lib/server/services/groceries.ts122
-rw-r--r--src/lib/server/services/references.test.ts70
-rw-r--r--src/lib/server/services/references.ts38
-rw-r--r--src/lib/server/services/types.ts6
-rw-r--r--src/lib/server/services/validation.ts45
13 files changed, 505 insertions, 19 deletions
diff --git a/src/lib/components/Nav.svelte b/src/lib/components/Nav.svelte
index 725c098..5558f6f 100644
--- a/src/lib/components/Nav.svelte
+++ b/src/lib/components/Nav.svelte
@@ -4,7 +4,8 @@
<nav class="navbar">
<a href="/" class:active={$page.url.pathname === '/'}>Liste</a>
- <a href="/catalog" class:active={$page.url.pathname === '/catalog'}>Catalogue</a
+ <a href="/catalog" class:active={$page.url.pathname === '/catalog'}
+ >Catalogue</a
>
<a href="/categories" class:active={$page.url.pathname === '/categories'}
>Catégories</a
diff --git a/src/lib/server/db/groceries.test.ts b/src/lib/server/db/groceries.test.ts
index bd14040..2f8baed 100644
--- a/src/lib/server/db/groceries.test.ts
+++ b/src/lib/server/db/groceries.test.ts
@@ -1,27 +1,14 @@
import { describe, it, expect, beforeEach } from 'vitest';
-import { drizzle } from 'drizzle-orm/libsql';
-import { createClient } from '@libsql/client';
import { groceries, categories } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
+import { createTestDb } from './test-helper';
describe('groceries add', () => {
- const client = createClient({ url: ':memory:' });
- const db = drizzle(client, { schema: { groceries, categories } });
+ let db: Awaited<ReturnType<typeof createTestDb>>['db'];
beforeEach(async () => {
- await db.run(`DROP TABLE IF EXISTS groceries`);
- await db.run(`DROP TABLE IF EXISTS categories`);
- await db.run(`CREATE TABLE categories (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL
- )`);
- await db.run(`CREATE TABLE groceries (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- checked INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL,
- category_id INTEGER REFERENCES categories(id)
- )`);
+ const testDb = await createTestDb();
+ db = testDb.db;
});
async function addItem(name: string, categoryId?: number | null) {
diff --git a/src/lib/server/db/ondelete.test.ts b/src/lib/server/db/ondelete.test.ts
new file mode 100644
index 0000000..c2c86de
--- /dev/null
+++ b/src/lib/server/db/ondelete.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { groceries, categories } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+import { createTestDb } from './test-helper';
+
+describe('category delete behavior', () => {
+ let db: Awaited<ReturnType<typeof createTestDb>>['db'];
+
+ beforeEach(async () => {
+ const testDb = await createTestDb();
+ db = testDb.db;
+ });
+
+ it('sets category_id to null when a referenced category is deleted', async () => {
+ await db.insert(categories).values({ name: 'Boissons' });
+ const cats = await db.select().from(categories);
+ const catId = cats[0].id;
+
+ await db.insert(groceries).values({
+ name: 'lait',
+ checked: false,
+ createdAt: new Date().toISOString(),
+ categoryId: catId,
+ });
+
+ await db.delete(categories).where(eq(categories.id, catId));
+
+ const items = await db.select().from(groceries);
+ expect(items).toHaveLength(1);
+ expect(items[0].categoryId).toBeNull();
+ });
+});
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 9113d9b..fae002d 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -10,7 +10,9 @@ export const groceries = sqliteTable('groceries', {
name: text('name').notNull().unique(),
checked: integer('checked', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(),
- categoryId: integer('category_id').references(() => categories.id),
+ categoryId: integer('category_id').references(() => categories.id, {
+ onDelete: 'set null',
+ }),
});
export const references = sqliteTable('references', {
diff --git a/src/lib/server/db/test-helper.ts b/src/lib/server/db/test-helper.ts
new file mode 100644
index 0000000..5bae681
--- /dev/null
+++ b/src/lib/server/db/test-helper.ts
@@ -0,0 +1,13 @@
+import { createClient } from '@libsql/client';
+import { drizzle } from 'drizzle-orm/libsql';
+import { migrate } from 'drizzle-orm/libsql/migrator';
+import * as schema from './schema';
+
+export async function createTestDb() {
+ const client = createClient({ url: ':memory:' });
+ const db = drizzle(client, { schema });
+
+ await migrate(db, { migrationsFolder: './drizzle' });
+
+ return { db, client };
+}
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<ReturnType<typeof createTestDb>>['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<Category[]> {
+ return db.select().from(categories).orderBy(asc(categories.name));
+}
+
+export async function add(name: string, db = defaultDb): Promise<Category> {
+ const [inserted] = await db.insert(categories).values({ name }).returning();
+ return inserted;
+}
+
+export async function deleteCategory(
+ id: number,
+ db = defaultDb,
+): Promise<void> {
+ 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<ReturnType<typeof createTestDb>>['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<typeof schema>;
+
+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<void> {
+ 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<void> {
+ 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<number, Reference>();
+ 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<ReturnType<typeof createTestDb>>['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<Reference[]> {
+ 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<Reference> {
+ const [inserted] = await db
+ .insert(references)
+ .values({ groceryId, reference, price, unit, qualityScore })
+ .returning();
+ return inserted;
+}
+
+export async function deleteReference(
+ id: number,
+ db = defaultDb,
+): Promise<void> {
+ 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<typeof categories>;
+export type Grocery = InferSelectModel<typeof groceries>;
+export type Reference = InferSelectModel<typeof references>;
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;
+}