From 3a6876f7e8ea4b89432a5d17472e47e92acbc72b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 13:05:28 +0200 Subject: [PATCH] test(18-02): add failing tests for global item service and seed - 10 test cases covering search, owner count, link/unlink, seed idempotency - Added globalItems/itemGlobalLinks tables to SQLite schema - Added Zod schemas and types for global items - Created 18-item bikepacking gear seed data JSON --- drizzle/0010_demonic_rawhide_kid.sql | 21 + drizzle/meta/0010_snapshot.json | 1005 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/global-items-seed.json | 146 +++ src/db/schema.ts | 25 + src/shared/schemas.ts | 9 + src/shared/types.ts | 8 + tests/services/global-item.service.test.ts | 172 ++++ 8 files changed, 1393 insertions(+) create mode 100644 drizzle/0010_demonic_rawhide_kid.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 src/db/global-items-seed.json create mode 100644 tests/services/global-item.service.test.ts diff --git a/drizzle/0010_demonic_rawhide_kid.sql b/drizzle/0010_demonic_rawhide_kid.sql new file mode 100644 index 0000000..3064c93 --- /dev/null +++ b/drizzle/0010_demonic_rawhide_kid.sql @@ -0,0 +1,21 @@ +CREATE TABLE `global_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `brand` text NOT NULL, + `model` text NOT NULL, + `category` text, + `weight_grams` real, + `price_cents` integer, + `image_url` text, + `description` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `item_global_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `item_id` integer NOT NULL, + `global_item_id` integer NOT NULL, + FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`global_item_id`) REFERENCES `global_items`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `item_global_links_item_id_unique` ON `item_global_links` (`item_id`); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..f5e6ab9 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1005 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92bc9490-0c70-4628-a3c7-20815e777a70", + "prevId": "ec8780d0-5541-41b1-974d-399f30e83364", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'package'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_items": { + "name": "global_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_global_links": { + "name": "item_global_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "item_global_links_item_id_unique": { + "name": "item_global_links_item_id_unique", + "columns": [ + "item_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "item_global_links_item_id_items_id_fk": { + "name": "item_global_links_item_id_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_global_links_global_item_id_global_items_id_fk": { + "name": "item_global_links_global_item_id_global_items_id_fk", + "tableFrom": "item_global_links", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_clients": { + "name": "oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_codes": { + "name": "oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_tokens": { + "name": "oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_tokens_access_token_hash_unique": { + "name": "oauth_tokens_access_token_hash_unique", + "columns": [ + "access_token_hash" + ], + "isUnique": true + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "columns": [ + "refresh_token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setup_items": { + "name": "setup_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'base'" + } + }, + "indexes": {}, + "foreignKeys": { + "setup_items_setup_id_setups_id_fk": { + "name": "setup_items_setup_id_setups_id_fk", + "tableFrom": "setup_items", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "setup_items_item_id_items_id_fk": { + "name": "setup_items_item_id_items_id_fk", + "tableFrom": "setup_items", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setups": { + "name": "setups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_candidates": { + "name": "thread_candidates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7c55e26..82bfda2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1775287060443, "tag": "0009_happy_mockingbird", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1775387093955, + "tag": "0010_demonic_rawhide_kid", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/global-items-seed.json b/src/db/global-items-seed.json new file mode 100644 index 0000000..2c82126 --- /dev/null +++ b/src/db/global-items-seed.json @@ -0,0 +1,146 @@ +[ + { + "brand": "Revelate Designs", + "model": "Terrapin System", + "category": "bags", + "weightGrams": 529, + "priceCents": 18500, + "description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount." + }, + { + "brand": "Apidura", + "model": "Expedition Handlebar Pack", + "category": "bags", + "weightGrams": 300, + "priceCents": 16000, + "description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket." + }, + { + "brand": "Ortlieb", + "model": "Frame-Pack Toptube", + "category": "bags", + "weightGrams": 180, + "priceCents": 7500, + "description": "4L waterproof top-tube bag with magnetic closure and reflective details." + }, + { + "brand": "Revelate Designs", + "model": "Tangle Frame Bag", + "category": "bags", + "weightGrams": 170, + "priceCents": 13500, + "description": "Full-frame bag with water-resistant construction and multiple internal pockets." + }, + { + "brand": "Big Agnes", + "model": "Copper Spur HV UL1", + "category": "shelters", + "weightGrams": 879, + "priceCents": 42000, + "description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles." + }, + { + "brand": "Tarptent", + "model": "Protrail Li", + "category": "shelters", + "weightGrams": 454, + "priceCents": 35000, + "description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric." + }, + { + "brand": "Outdoor Research", + "model": "Helium Bivy", + "category": "shelters", + "weightGrams": 510, + "priceCents": 24900, + "description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry." + }, + { + "brand": "Sea to Summit", + "model": "Spark SP1", + "category": "sleep-systems", + "weightGrams": 375, + "priceCents": 28000, + "description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down." + }, + { + "brand": "Nemo", + "model": "Tensor Ultralight Insulated Regular", + "category": "sleep-systems", + "weightGrams": 425, + "priceCents": 18000, + "description": "3-inch thick insulated sleeping pad with R-value 4.2 and Spaceframe baffles." + }, + { + "brand": "Therm-a-Rest", + "model": "NeoAir XLite NXT", + "category": "sleep-systems", + "weightGrams": 354, + "priceCents": 22000, + "description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve." + }, + { + "brand": "MSR", + "model": "PocketRocket 2", + "category": "cooking", + "weightGrams": 73, + "priceCents": 5500, + "description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes." + }, + { + "brand": "Toaks", + "model": "Titanium 750ml Pot", + "category": "cooking", + "weightGrams": 103, + "priceCents": 3300, + "description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity." + }, + { + "brand": "Katadyn", + "model": "BeFree 1.0L", + "category": "hydration", + "weightGrams": 59, + "priceCents": 4500, + "description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask." + }, + { + "brand": "HydraPak", + "model": "Seeker 2L", + "category": "hydration", + "weightGrams": 73, + "priceCents": 1800, + "description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter." + }, + { + "brand": "Nitecore", + "model": "NU25 UL", + "category": "lighting", + "weightGrams": 28, + "priceCents": 3600, + "description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode." + }, + { + "brand": "Exposure Lights", + "model": "Revo Dynamo", + "category": "lighting", + "weightGrams": 130, + "priceCents": 22000, + "description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output." + }, + { + "brand": "Surly", + "model": "24-Pack Rack", + "category": "racks", + "weightGrams": 750, + "priceCents": 10000, + "description": "Front rack for bikepacking with 24-pack platform, fits most forks with mid-blade eyelets." + }, + { + "brand": "Salsa", + "model": "Anything Cage HD", + "category": "accessories", + "weightGrams": 80, + "priceCents": 2500, + "description": "Heavy-duty bottle cage for oversized loads like dry bags and fuel canisters." + } +] diff --git a/src/db/schema.ts b/src/db/schema.ts index 9e7113b..c63dc98 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -127,6 +127,31 @@ export const apiKeys = sqliteTable("api_keys", { .$defaultFn(() => new Date()), }); +export const globalItems = sqliteTable("global_items", { + id: integer("id").primaryKey({ autoIncrement: true }), + brand: text("brand").notNull(), + model: text("model").notNull(), + category: text("category"), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const itemGlobalLinks = sqliteTable("item_global_links", { + id: integer("id").primaryKey({ autoIncrement: true }), + itemId: integer("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }) + .unique(), + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), +}); + export const oauthClients = sqliteTable("oauth_clients", { id: integer("id").primaryKey({ autoIncrement: true }), clientId: text("client_id").notNull().unique(), diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index ed34157..af26501 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -89,3 +89,12 @@ export const classificationSchema = z.enum(["base", "worn", "consumable"]); export const updateClassificationSchema = z.object({ classification: classificationSchema, }); + +// Global item schemas +export const searchGlobalItemsSchema = z.object({ + q: z.string().optional(), +}); + +export const linkItemSchema = z.object({ + globalItemId: z.number().int().positive(), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index f96624e..ddf024c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,8 @@ import type { z } from "zod"; import type { categories, + globalItems, + itemGlobalLinks, items, setupItems, setups, @@ -13,8 +15,10 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, + linkItemSchema, reorderCandidatesSchema, resolveThreadSchema, + searchGlobalItemsSchema, syncSetupItemsSchema, updateCandidateSchema, updateCategorySchema, @@ -49,3 +53,7 @@ export type Thread = typeof threads.$inferSelect; export type ThreadCandidate = typeof threadCandidates.$inferSelect; export type Setup = typeof setups.$inferSelect; export type SetupItem = typeof setupItems.$inferSelect; +export type GlobalItem = typeof globalItems.$inferSelect; +export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; +export type SearchGlobalItems = z.infer; +export type LinkItem = z.infer; diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts new file mode 100644 index 0000000..c75d108 --- /dev/null +++ b/tests/services/global-item.service.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { globalItems, itemGlobalLinks, items } from "../../src/db/schema.ts"; +import { + getGlobalItemWithOwnerCount, + linkItemToGlobal, + searchGlobalItems, + unlinkItemFromGlobal, +} from "../../src/server/services/global-item.service.ts"; +import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; +import { createTestDb } from "../helpers/db.ts"; + +type TestDb = ReturnType; + +function insertGlobalItem( + db: TestDb, + data: { + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + }, +) { + return db + .insert(globalItems) + .values({ + brand: data.brand, + model: data.model, + category: data.category ?? null, + weightGrams: data.weightGrams ?? null, + priceCents: data.priceCents ?? null, + }) + .returning() + .get(); +} + +function insertItem(db: TestDb, name: string) { + return db + .insert(items) + .values({ name, categoryId: 1 }) + .returning() + .get(); +} + +describe("Global Item Service", () => { + let db: TestDb; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("searchGlobalItems", () => { + it("returns all global items when no query provided", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db); + expect(results).toHaveLength(2); + }); + + it("returns items matching brand (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "revelate"); + expect(results).toHaveLength(1); + expect(results[0].brand).toBe("Revelate Designs"); + }); + + it("returns items matching model (case-insensitive)", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "HANDLEBAR"); + expect(results).toHaveLength(1); + expect(results[0].model).toBe("Handlebar Pack"); + }); + + it("does not match everything with wildcard chars", () => { + insertGlobalItem(db, { brand: "Revelate Designs", model: "Terrapin System" }); + insertGlobalItem(db, { brand: "Apidura", model: "Handlebar Pack" }); + + const results = searchGlobalItems(db, "100%"); + expect(results).toHaveLength(0); + }); + }); + + describe("getGlobalItemWithOwnerCount", () => { + it("returns item with ownerCount 0 when no links", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(0); + expect(result!.brand).toBe("MSR"); + }); + + it("returns ownerCount matching number of linked items", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item1 = insertItem(db, "My Stove"); + const item2 = insertItem(db, "Another Stove"); + + db.insert(itemGlobalLinks) + .values({ itemId: item1.id, globalItemId: gi.id }) + .run(); + db.insert(itemGlobalLinks) + .values({ itemId: item2.id, globalItemId: gi.id }) + .run(); + + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result).not.toBeNull(); + expect(result!.ownerCount).toBe(2); + }); + + it("returns null for non-existent id", () => { + const result = getGlobalItemWithOwnerCount(db, 9999); + expect(result).toBeNull(); + }); + }); + + describe("linkItemToGlobal", () => { + it("creates link and returns link row", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + const link = linkItemToGlobal(db, item.id, gi.id); + expect(link.itemId).toBe(item.id); + expect(link.globalItemId).toBe(gi.id); + }); + + it("throws when item already linked", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + expect(() => linkItemToGlobal(db, item.id, gi.id)).toThrow(); + }); + }); + + describe("unlinkItemFromGlobal", () => { + it("removes the link", () => { + const gi = insertGlobalItem(db, { brand: "MSR", model: "PocketRocket 2" }); + const item = insertItem(db, "My Stove"); + + linkItemToGlobal(db, item.id, gi.id); + const deleted = unlinkItemFromGlobal(db, item.id); + expect(deleted).toBe(1); + + // Verify link is gone + const result = getGlobalItemWithOwnerCount(db, gi.id); + expect(result!.ownerCount).toBe(0); + }); + }); + + describe("seedGlobalItems", () => { + it("inserts seed data on first call", () => { + seedGlobalItems(db); + const all = db.select().from(globalItems).all(); + expect(all.length).toBeGreaterThan(0); + }); + + it("is idempotent on second call", () => { + seedGlobalItems(db); + const countAfterFirst = db.select().from(globalItems).all().length; + + seedGlobalItems(db); + const countAfterSecond = db.select().from(globalItems).all().length; + + expect(countAfterSecond).toBe(countAfterFirst); + }); + }); +});