From 11db402bd3ef551df4e70aedaf0c433f8513a9e7 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 24 Nov 2025 07:20:55 +1000 Subject: [PATCH] Deduplicate seed data and add tests --- src/db/seed.test.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++ src/db/seed.ts | 78 ++++++++++++++++++++++------ 2 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 src/db/seed.test.ts diff --git a/src/db/seed.test.ts b/src/db/seed.test.ts new file mode 100644 index 0000000..f430054 --- /dev/null +++ b/src/db/seed.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; +import { seedAreas, seedCategories, seedDatabase, seedProducts } from './seed'; +import { Area } from '../models/Area'; +import { Category } from '../models/Category'; +import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product'; +import { StockFillDB } from './index'; + +const normalizeName = (name: string) => name.trim().toLowerCase(); + +class MockTable { + constructor(public items: T[] = []) {} + + async count() { + return this.items.length; + } + + async bulkAdd(records: T[]) { + this.items.push(...records); + } + + async bulkDelete(ids: string[]) { + this.items = this.items.filter((item) => !ids.includes(item.id)); + } + + async toArray() { + return [...this.items]; + } +} + +const buildArea = (overrides: Partial = {}): Area => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Area', + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const buildProduct = (overrides: Partial = {}): Product => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Product', + category: overrides.category ?? 'Category', + unit_type: overrides.unit_type ?? DEFAULT_UNIT_TYPE, + bulk_name: overrides.bulk_name ?? DEFAULT_BULK_NAME, + barcode: overrides.barcode, + archived: overrides.archived ?? false, + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const buildCategory = (overrides: Partial = {}): Category => ({ + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Category', + created_at: overrides.created_at ?? Date.now(), + updated_at: overrides.updated_at ?? Date.now(), +}); + +const createMockDb = (options: { + areas?: Area[]; + products?: Product[]; + categories?: Category[]; +} = {}) => { + const db = { + areas: new MockTable(options.areas ?? []), + products: new MockTable(options.products ?? []), + categories: new MockTable(options.categories ?? []), + pickLists: new MockTable(), + pickItems: new MockTable(), + } as unknown as StockFillDB; + + return db; +}; + +describe('seedDatabase', () => { + it('deduplicates seeded areas, categories, and products', async () => { + const duplicateSeedArea = buildArea({ name: seedAreas[0] }); + const trailingSpaceArea = buildArea({ name: `${seedAreas[0]} ` }); + const customArea = buildArea({ name: 'Produce' }); + + const duplicateProduct = buildProduct({ name: seedProducts[0].name, category: seedProducts[0].category }); + const duplicateProductWithWhitespace = buildProduct({ name: `${seedProducts[0].name} `, category: seedProducts[0].category }); + const customProduct = buildProduct({ name: 'Custom Item', category: 'Specials' }); + + const duplicateCategory = buildCategory({ name: seedCategories[0] }); + const trailingSpaceCategory = buildCategory({ name: `${seedCategories[0]} ` }); + + const db = createMockDb({ + areas: [duplicateSeedArea, trailingSpaceArea, customArea], + products: [duplicateProduct, duplicateProductWithWhitespace, customProduct], + categories: [duplicateCategory, trailingSpaceCategory], + }); + + await seedDatabase(db); + + const areas = await db.areas.toArray(); + const areaNames = areas.map((area) => normalizeName(area.name)); + const seededAreaNames = new Set(seedAreas.map(normalizeName)); + + expect(areaNames.filter((name) => name === normalizeName(seedAreas[0]))).toHaveLength(1); + expect(new Set(areaNames.filter((name) => seededAreaNames.has(name)))).toEqual(seededAreaNames); + expect(areaNames).toContain(normalizeName(customArea.name)); + + const products = await db.products.toArray(); + const seededProductNames = new Set(seedProducts.map((product) => normalizeName(product.name))); + const productNamesInDb = products.map((product) => normalizeName(product.name)); + + expect(productNamesInDb.filter((name) => name === normalizeName(seedProducts[0].name))).toHaveLength(1); + expect(new Set(productNamesInDb.filter((name) => seededProductNames.has(name)))).toEqual( + seededProductNames, + ); + expect(productNamesInDb).toContain(normalizeName(customProduct.name)); + + const categories = await db.categories.toArray(); + const seededCategoryNames = new Set(seedCategories.map(normalizeName)); + const categoryNamesInDb = categories.map((category) => normalizeName(category.name)); + + expect(categoryNamesInDb.filter((name) => name === normalizeName(seedCategories[0]))).toHaveLength(1); + expect(new Set(categoryNamesInDb.filter((name) => seededCategoryNames.has(name)))).toEqual( + seededCategoryNames, + ); + }); +}); diff --git a/src/db/seed.ts b/src/db/seed.ts index 5e60521..7d6a497 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,10 +1,13 @@ +import { Table } from 'dexie'; import { v4 as uuidv4 } from 'uuid'; import { StockFillDB } from './index'; import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product'; const now = () => Date.now(); -const seedProducts = [ +export const seedAreas = ['Drinks', 'Snacks', 'Dairy']; + +export const seedProducts = [ { name: 'Nutrient Water Endurance', category: 'Drinks' }, { name: 'Nutrient Water Focus', category: 'Drinks' }, { name: 'Cocobella Choc', category: 'Drinks' }, @@ -33,10 +36,39 @@ const seedProducts = [ { name: 'Mars Bar', category: 'Chocolates' }, ]; -const seedCategories = Array.from( +export const seedCategories = Array.from( new Set(seedProducts.map(({ category }) => category)), ).sort(); +const normalizeName = (name: string) => name.trim().toLowerCase(); + +const dedupeSeedRecords = async ( + table: Table, + seededNames: Set, +) => { + const existing = await table.toArray(); + const seen = new Set(); + const duplicateIds: string[] = []; + + existing.forEach((record) => { + const normalized = normalizeName(record.name); + if (!seededNames.has(normalized)) return; + + if (seen.has(normalized)) { + duplicateIds.push(record.id); + return; + } + + seen.add(normalized); + }); + + if (duplicateIds.length > 0) { + await table.bulkDelete(duplicateIds); + } + + return seen; +}; + const buildProductRecord = (product: { name: string; category: string }) => ({ id: uuidv4(), name: product.name, @@ -49,19 +81,30 @@ const buildProductRecord = (product: { name: string; category: string }) => ({ }); export const seedDatabase = async (db: StockFillDB) => { - const areaCount = await db.areas.count(); - if (areaCount === 0) { - await db.areas.bulkAdd([ - { id: uuidv4(), name: 'Drinks', created_at: now(), updated_at: now() }, - { id: uuidv4(), name: 'Snacks', created_at: now(), updated_at: now() }, - { id: uuidv4(), name: 'Dairy', created_at: now(), updated_at: now() }, - ]); + const seededAreaNames = new Set(seedAreas.map(normalizeName)); + const existingSeedAreas = await dedupeSeedRecords(db.areas, seededAreaNames); + const missingAreas = seedAreas.filter((area) => !existingSeedAreas.has(normalizeName(area))); + + if (missingAreas.length > 0) { + await db.areas.bulkAdd( + missingAreas.map((name) => ({ + id: uuidv4(), + name, + created_at: now(), + updated_at: now(), + })), + ); } - const categoryCount = await db.categories.count(); - if (categoryCount === 0) { + const seededCategoryNames = new Set(seedCategories.map(normalizeName)); + const existingSeedCategories = await dedupeSeedRecords(db.categories, seededCategoryNames); + const missingCategories = seedCategories.filter( + (category) => !existingSeedCategories.has(normalizeName(category)), + ); + + if (missingCategories.length > 0) { await db.categories.bulkAdd( - seedCategories.map((category) => ({ + missingCategories.map((category) => ({ id: uuidv4(), name: category, created_at: now(), @@ -70,8 +113,13 @@ export const seedDatabase = async (db: StockFillDB) => { ); } - const productCount = await db.products.count(); - if (productCount === 0) { - await db.products.bulkAdd(seedProducts.map(buildProductRecord)); + const seededProductNames = new Set(seedProducts.map(({ name }) => normalizeName(name))); + const existingSeedProducts = await dedupeSeedRecords(db.products, seededProductNames); + const missingProducts = seedProducts.filter( + (product) => !existingSeedProducts.has(normalizeName(product.name)), + ); + + if (missingProducts.length > 0) { + await db.products.bulkAdd(missingProducts.map(buildProductRecord)); } };