Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

View file

@ -3,3 +3,5 @@ export * from './medicine.enums.js';
export * from './cabinet.enums.js';
export * from './cabinet-event.enums.js';
export * from './regimen.enums.js';
export * from './refill.enums.js';
export * from './purchase.enums.js';

View file

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { PurchaseStatus } from './purchase.enums.js';
describe(PurchaseStatus.name, () => {
it('has exactly 2 values', () => {
expect(Object.values(PurchaseStatus)).toHaveLength(2);
});
it.each([
['ORDERED', 'ordered'],
['IN_CABINET', 'in_cabinet'],
])('%s = %s', (key, value) => {
expect(PurchaseStatus[key as keyof typeof PurchaseStatus]).toBe(value);
});
});

View file

@ -0,0 +1,4 @@
export enum PurchaseStatus {
ORDERED = 'ordered',
IN_CABINET = 'in_cabinet',
}

View file

@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { RefillListStatus } from './refill.enums.js';
describe(RefillListStatus.name, () => {
it('has exactly 4 values', () => {
expect(Object.values(RefillListStatus)).toHaveLength(4);
});
it.each([
['ACTIVE', 'active'],
['SHOPPING', 'shopping'],
['COMPLETED', 'completed'],
['ARCHIVED', 'archived'],
])('%s = %s', (key, value) => {
expect(RefillListStatus[key as keyof typeof RefillListStatus]).toBe(value);
});
});

View file

@ -0,0 +1,6 @@
export enum RefillListStatus {
ACTIVE = 'active',
SHOPPING = 'shopping',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}

View file

@ -7,3 +7,7 @@ export * from './cabinet-event.js';
export * from './regimen.js';
export * from './organizer-fill.js';
export * from './burn-rate.js';
export * from './store.js';
export * from './medicine-price.js';
export * from './refill.js';
export * from './purchase.js';

View file

@ -0,0 +1,56 @@
import type { DosageUnit } from '../enums/medicine.enums.js';
export interface MedicinePriceRecord {
id: string;
householdId: string;
medicineProductId: string;
medicineProductBrand: string;
medicineId: string;
medicineName: string;
storeId: string;
storeName: string;
price: number;
currency: string;
quantity: number;
unit: DosageUnit;
pricePerUnit: number;
date: Date;
isInsurancePrice: boolean;
notes?: string;
createdBy: string;
createdAt: Date;
}
export interface StoreComparison {
storeId: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}
export interface MedicineSpendingAnalytics {
spendingOverTime: { period: string; total: number }[];
topBySpending: {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}[];
spendingByStore: {
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}[];
priceAlerts: {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}[];
}

View file

@ -0,0 +1,30 @@
import type { PurchaseStatus } from '../enums/purchase.enums.js';
export interface PurchaseItem {
id: string;
medicineProductId?: string;
medicineId?: string;
foodProductId?: string;
name: string;
quantity: number;
unit: string;
actualPrice?: number;
currency?: string;
priceRecordId?: string;
addedToCabinet: boolean;
}
export interface Purchase {
id: string;
householdId: string;
storeId: string;
storeName: string;
status: PurchaseStatus;
items: PurchaseItem[];
notes?: string;
purchasedAt: Date;
receivedAt?: Date;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,57 @@
import type { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
import type { RefillListStatus } from '../enums/refill.enums.js';
export interface RefillAlert {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
daysUntilEmpty: number;
dailyConsumption: number;
currentStock: number;
pendingOrderStock: number;
daysUntilEmptyWithOrders: number | null;
suggestedQuantity: number;
lastKnownPrice?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
cheapestOption?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
}
export interface RefillListItem {
id: string;
medicineId: string;
medicineName: string;
quantity: number;
unit: DosageUnit;
estimatedPrice?: number;
actualPrice?: number;
checked: boolean;
checkedAt?: Date;
addedToCabinet: boolean;
storeId?: string;
notes?: string;
}
export interface RefillList {
id: string;
householdId: string;
name: string;
items: RefillListItem[];
status: RefillListStatus;
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,17 @@
export interface Store {
id: string;
householdId: string;
name: string;
address?: string;
location?: {
lat: number;
lng: number;
};
url?: string;
notes?: string;
tags: string[];
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -5,3 +5,7 @@ export * from './cabinet.schemas.js';
export * from './cabinet-event.schemas.js';
export * from './regimen.schemas.js';
export * from './organizer.schemas.js';
export * from './store.schemas.js';
export * from './medicine-price.schemas.js';
export * from './refill.schemas.js';
export * from './purchase.schemas.js';

View file

@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { DosageUnit } from '../enums/medicine.enums.js';
import {
CreateMedicinePriceRecordSchema,
MedicinePriceHistoryQuerySchema,
MedicinePriceAnalyticsQuerySchema,
} from './medicine-price.schemas.js';
const validInput = {
medicineProductId: 'prod-1',
medicineId: 'med-1',
storeId: 'store-1',
price: 9.99,
currency: 'USD',
quantity: 30,
unit: DosageUnit.TABLET,
};
describe('CreateMedicinePriceRecordSchema', () => {
it('accepts valid input', () => {
expect(CreateMedicinePriceRecordSchema.safeParse(validInput).success).toBe(true);
});
it('defaults isInsurancePrice to false', () => {
const result = CreateMedicinePriceRecordSchema.parse(validInput);
expect(result.isInsurancePrice).toBe(false);
});
it('accepts optional date as ISO datetime', () => {
const result = CreateMedicinePriceRecordSchema.safeParse({
...validInput,
date: '2026-01-15T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects non-positive price', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false);
});
it('rejects non-positive quantity', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false);
});
it('rejects invalid unit', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false);
});
});
describe('MedicinePriceHistoryQuerySchema', () => {
it('applies default limit', () => {
const result = MedicinePriceHistoryQuerySchema.parse({});
expect(result.limit).toBe(20);
});
it('accepts date range filters', () => {
const result = MedicinePriceHistoryQuerySchema.parse({
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-03-31T00:00:00.000Z',
});
expect(result.startDate).toBeTruthy();
expect(result.endDate).toBeTruthy();
});
});
describe('MedicinePriceAnalyticsQuerySchema', () => {
it('defaults to month period', () => {
const result = MedicinePriceAnalyticsQuerySchema.parse({});
expect(result.period).toBe('month');
});
it.each(['month', 'quarter', 'year'])('accepts period %s', (period) => {
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period }).success).toBe(true);
});
it('rejects invalid period', () => {
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period: 'week' }).success).toBe(false);
});
});

View file

@ -0,0 +1,98 @@
import { z } from 'zod/v4';
import { DosageUnit } from '../enums/medicine.enums.js';
export const CreateMedicinePriceRecordSchema = z.object({
medicineProductId: z.string().min(1),
medicineId: z.string().min(1),
storeId: z.string().min(1),
price: z.number().positive(),
currency: z.string().min(1).max(10),
quantity: z.number().positive(),
unit: z.nativeEnum(DosageUnit),
date: z.iso.datetime().optional(),
isInsurancePrice: z.boolean().default(false),
notes: z.string().max(1000).trim().optional(),
});
export const MedicinePriceHistoryQuerySchema = z.object({
storeId: z.string().optional(),
startDate: z.iso.datetime().optional(),
endDate: z.iso.datetime().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const MedicinePriceAnalyticsQuerySchema = z.object({
period: z.enum(['month', 'quarter', 'year']).default('month'),
});
export const MedicinePriceRecordResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
medicineProductId: z.string(),
medicineProductBrand: z.string(),
medicineId: z.string(),
medicineName: z.string(),
storeId: z.string(),
storeName: z.string(),
price: z.number(),
currency: z.string(),
quantity: z.number(),
unit: z.string(),
pricePerUnit: z.number(),
date: z.string(),
isInsurancePrice: z.boolean(),
notes: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
});
export const MedicinePriceHistoryResponseSchema = z.object({
data: z.array(MedicinePriceRecordResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
}),
});
export const StoreComparisonItemSchema = z.object({
storeId: z.string(),
storeName: z.string(),
latestPrice: z.number(),
latestPricePerUnit: z.number(),
currency: z.string(),
date: z.string(),
isInsurancePrice: z.boolean(),
});
export const StoreComparisonResponseSchema = z.object({
data: z.array(StoreComparisonItemSchema),
});
export const MedicineSpendingAnalyticsResponseSchema = z.object({
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
topBySpending: z.array(z.object({
medicineId: z.string(),
medicineName: z.string(),
totalSpent: z.number(),
avgPricePerUnit: z.number(),
})),
spendingByStore: z.array(z.object({
storeId: z.string(),
storeName: z.string(),
totalSpent: z.number(),
purchaseCount: z.number(),
})),
priceAlerts: z.array(z.object({
medicineId: z.string(),
medicineName: z.string(),
storeName: z.string(),
previousPrice: z.number(),
currentPrice: z.number(),
changePercent: z.number(),
})),
});
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
export type MedicinePriceHistoryQueryInput = z.infer<typeof MedicinePriceHistoryQuerySchema>;
export type MedicinePriceAnalyticsQueryInput = z.infer<typeof MedicinePriceAnalyticsQuerySchema>;

View file

@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import {
CreatePurchaseItemSchema,
CreatePurchaseSchema,
UpdatePurchaseSchema,
PurchaseQuerySchema,
} from './purchase.schemas.js';
describe('CreatePurchaseItemSchema', () => {
it('accepts minimal valid item', () => {
const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' });
expect(result.success).toBe(true);
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
});
it('accepts item with all fields', () => {
const result = CreatePurchaseItemSchema.safeParse({
medicineProductId: 'mp-1',
name: 'Tylenol 30ct',
quantity: 30,
unit: 'tablet',
actualPrice: 9.99,
currency: 'USD',
priceRecordId: 'pr-1',
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false);
});
it('rejects non-positive quantity', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false);
});
it('rejects empty unit', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false);
});
it('trims name whitespace', () => {
const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' });
expect(result.name).toBe('Tylenol');
});
});
describe('CreatePurchaseSchema', () => {
const validItem = { name: 'Tylenol', quantity: 30, unit: 'tablet' };
it('defaults status to in_cabinet', () => {
const result = CreatePurchaseSchema.parse({ storeId: 'st-1', items: [validItem] });
expect(result.status).toBe('in_cabinet');
});
it('accepts ordered status', () => {
const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] });
expect(result.success).toBe(true);
});
it('rejects empty storeId', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: '', items: [validItem] }).success).toBe(false);
});
it('rejects empty items array', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [] }).success).toBe(false);
});
it('rejects invalid status', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false);
});
it('accepts optional purchasedAt datetime', () => {
const result = CreatePurchaseSchema.safeParse({
storeId: 'st-1',
items: [validItem],
purchasedAt: '2026-01-15T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects invalid purchasedAt', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false);
});
});
describe('UpdatePurchaseSchema', () => {
it('accepts empty object', () => {
expect(UpdatePurchaseSchema.safeParse({}).success).toBe(true);
});
it('accepts notes only', () => {
expect(UpdatePurchaseSchema.safeParse({ notes: 'updated' }).success).toBe(true);
});
it('trims notes whitespace', () => {
const result = UpdatePurchaseSchema.parse({ notes: ' hello ' });
expect(result.notes).toBe('hello');
});
});
describe('PurchaseQuerySchema', () => {
it('defaults limit to 20', () => {
const result = PurchaseQuerySchema.parse({});
expect(result.limit).toBe(20);
});
it('accepts status filter', () => {
expect(PurchaseQuerySchema.safeParse({ status: 'ordered' }).success).toBe(true);
expect(PurchaseQuerySchema.safeParse({ status: 'in_cabinet' }).success).toBe(true);
});
it('rejects invalid status', () => {
expect(PurchaseQuerySchema.safeParse({ status: 'unknown' }).success).toBe(false);
});
it('coerces string limit', () => {
const result = PurchaseQuerySchema.parse({ limit: '50' });
expect(result.limit).toBe(50);
});
});

View file

@ -0,0 +1,74 @@
import { z } from 'zod/v4';
export const CreatePurchaseItemSchema = z.object({
medicineProductId: z.string().optional(),
name: z.string().min(1).max(200).trim(),
quantity: z.number().positive(),
unit: z.string().min(1),
actualPrice: z.number().positive().optional(),
currency: z.string().min(1).max(10).optional(),
priceRecordId: z.string().optional(),
});
export const CreatePurchaseSchema = z.object({
storeId: z.string().min(1),
status: z.enum(['ordered', 'in_cabinet']).default('in_cabinet'),
items: z.array(CreatePurchaseItemSchema).min(1),
notes: z.string().max(1000).trim().optional(),
purchasedAt: z.iso.datetime().optional(),
});
export const UpdatePurchaseSchema = z.object({
notes: z.string().max(1000).trim().optional(),
items: z.array(CreatePurchaseItemSchema).min(1).optional(),
});
export const PurchaseItemResponseSchema = z.object({
_id: z.string(),
medicineProductId: z.string().optional(),
medicineId: z.string().optional(),
foodProductId: z.string().optional(),
name: z.string(),
quantity: z.number(),
unit: z.string(),
actualPrice: z.number().optional(),
currency: z.string().optional(),
priceRecordId: z.string().optional(),
addedToCabinet: z.boolean(),
});
export const PurchaseResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
storeId: z.string(),
storeName: z.string(),
status: z.enum(['ordered', 'in_cabinet']),
items: z.array(PurchaseItemResponseSchema),
notes: z.string().optional(),
purchasedAt: z.string(),
receivedAt: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const PurchaseListResponseSchema = z.object({
data: z.array(PurchaseResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const PurchaseQuerySchema = z.object({
status: z.enum(['ordered', 'in_cabinet']).optional(),
storeId: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type CreatePurchaseItemInput = z.infer<typeof CreatePurchaseItemSchema>;
export type CreatePurchaseInput = z.infer<typeof CreatePurchaseSchema>;
export type UpdatePurchaseInput = z.infer<typeof UpdatePurchaseSchema>;
export type PurchaseQueryInput = z.infer<typeof PurchaseQuerySchema>;

View file

@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import { DosageUnit } from '../enums/medicine.enums.js';
import { RefillListStatus } from '../enums/refill.enums.js';
import {
CreateRefillListSchema,
UpdateRefillListSchema,
UpdateRefillListItemSchema,
RefillListQuerySchema,
RefillAlertQuerySchema,
} from './refill.schemas.js';
describe('CreateRefillListSchema', () => {
it('accepts minimal valid input', () => {
const result = CreateRefillListSchema.parse({ name: 'Weekly Refills' });
expect(result.fromAlerts).toBe(false);
expect(result.thresholdDays).toBe(7);
});
it('accepts fromAlerts with threshold', () => {
const result = CreateRefillListSchema.parse({
name: 'Auto List',
fromAlerts: true,
thresholdDays: 14,
});
expect(result.fromAlerts).toBe(true);
expect(result.thresholdDays).toBe(14);
});
it('accepts items array', () => {
const result = CreateRefillListSchema.safeParse({
name: 'Manual List',
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantity: 30,
unit: DosageUnit.TABLET,
},
],
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
expect(CreateRefillListSchema.safeParse({ name: '' }).success).toBe(false);
});
it('rejects item with non-positive quantity', () => {
expect(
CreateRefillListSchema.safeParse({
name: 'List',
items: [{ medicineId: 'med-1', medicineName: 'X', quantity: 0, unit: DosageUnit.TABLET }],
}).success,
).toBe(false);
});
it('trims name whitespace', () => {
const result = CreateRefillListSchema.parse({ name: ' My List ' });
expect(result.name).toBe('My List');
});
});
describe('UpdateRefillListSchema', () => {
it('accepts empty object', () => {
expect(UpdateRefillListSchema.safeParse({}).success).toBe(true);
});
it('accepts status update', () => {
const result = UpdateRefillListSchema.parse({ status: RefillListStatus.SHOPPING });
expect(result.status).toBe(RefillListStatus.SHOPPING);
});
it('rejects invalid status', () => {
expect(UpdateRefillListSchema.safeParse({ status: 'invalid' }).success).toBe(false);
});
});
describe('UpdateRefillListItemSchema', () => {
it('accepts checked=true', () => {
expect(UpdateRefillListItemSchema.safeParse({ checked: true }).success).toBe(true);
});
it('accepts actualPrice', () => {
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 });
expect(result.actualPrice).toBe(12.50);
});
it('rejects negative actualPrice', () => {
expect(UpdateRefillListItemSchema.safeParse({ actualPrice: -1 }).success).toBe(false);
});
});
describe('RefillListQuerySchema', () => {
it('applies default limit', () => {
expect(RefillListQuerySchema.parse({}).limit).toBe(20);
});
it('accepts status filter', () => {
const result = RefillListQuerySchema.parse({ status: RefillListStatus.ACTIVE });
expect(result.status).toBe(RefillListStatus.ACTIVE);
});
});
describe('RefillAlertQuerySchema', () => {
it('defaults thresholdDays to 7', () => {
expect(RefillAlertQuerySchema.parse({}).thresholdDays).toBe(7);
});
it('coerces thresholdDays from string', () => {
expect(RefillAlertQuerySchema.parse({ thresholdDays: '14' }).thresholdDays).toBe(14);
});
});

View file

@ -0,0 +1,115 @@
import { z } from 'zod/v4';
import { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
import { RefillListStatus } from '../enums/refill.enums.js';
export const CreateRefillListItemSchema = z.object({
medicineId: z.string().min(1),
medicineName: z.string().min(1),
quantity: z.number().positive(),
unit: z.nativeEnum(DosageUnit),
estimatedPrice: z.number().nonnegative().optional(),
storeId: z.string().optional(),
notes: z.string().max(500).trim().optional(),
});
export const CreateRefillListSchema = z.object({
name: z.string().min(1).max(200).trim(),
preferredStoreId: z.string().optional(),
fromAlerts: z.boolean().default(false),
thresholdDays: z.number().int().min(1).max(365).default(7),
items: z.array(CreateRefillListItemSchema).optional(),
});
export const UpdateRefillListSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
status: z.nativeEnum(RefillListStatus).optional(),
preferredStoreId: z.string().optional(),
});
export const UpdateRefillListItemSchema = z.object({
checked: z.boolean().optional(),
actualPrice: z.number().nonnegative().optional(),
storeId: z.string().optional(),
notes: z.string().max(500).trim().optional(),
});
export const RefillListQuerySchema = z.object({
status: z.nativeEnum(RefillListStatus).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const RefillAlertQuerySchema = z.object({
thresholdDays: z.coerce.number().int().min(1).max(365).default(7),
userId: z.string().optional(),
});
const PriceOptionSchema = z.object({
price: z.number(),
pricePerUnit: z.number(),
storeName: z.string(),
storeId: z.string(),
date: z.string(),
});
export const RefillAlertResponseSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
medicineStrength: z.number(),
medicineStrengthUnit: z.nativeEnum(StrengthUnit),
daysUntilEmpty: z.number(),
dailyConsumption: z.number(),
currentStock: z.number(),
pendingOrderStock: z.number().optional(),
daysUntilEmptyWithOrders: z.number().nullable().optional(),
suggestedQuantity: z.number(),
lastKnownPrice: PriceOptionSchema.optional(),
cheapestOption: PriceOptionSchema.optional(),
});
export const RefillListItemResponseSchema = z.object({
_id: z.string(),
medicineId: z.string(),
medicineName: z.string(),
quantity: z.number(),
unit: z.string(),
estimatedPrice: z.number().optional(),
actualPrice: z.number().optional(),
checked: z.boolean(),
checkedAt: z.string().optional(),
addedToCabinet: z.boolean(),
storeId: z.string().optional(),
notes: z.string().optional(),
});
export const RefillListResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
name: z.string(),
items: z.array(RefillListItemResponseSchema),
status: z.nativeEnum(RefillListStatus),
preferredStoreId: z.string().optional(),
totalEstimatedCost: z.number().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const RefillListListResponseSchema = z.object({
data: z.array(RefillListResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
}),
});
export const AddToCabinetResponseSchema = z.object({
addedCount: z.number(),
priceRecordsCreated: z.number(),
});
export type CreateRefillListInput = z.infer<typeof CreateRefillListSchema>;
export type UpdateRefillListInput = z.infer<typeof UpdateRefillListSchema>;
export type UpdateRefillListItemInput = z.infer<typeof UpdateRefillListItemSchema>;
export type RefillListQueryInput = z.infer<typeof RefillListQuerySchema>;
export type RefillAlertQueryInput = z.infer<typeof RefillAlertQuerySchema>;

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { CreateStoreSchema, UpdateStoreSchema, StoreQuerySchema } from './store.schemas.js';
describe('CreateStoreSchema', () => {
it('accepts minimal valid input', () => {
const result = CreateStoreSchema.safeParse({ name: 'Walgreens' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tags).toEqual([]);
expect(result.data.isActive).toBe(true);
}
});
it('accepts full valid input', () => {
const result = CreateStoreSchema.safeParse({
name: 'CVS Pharmacy',
address: '123 Main St',
location: { lat: 40.7128, lng: -74.006 },
url: 'https://cvs.com',
notes: 'Open 24h',
tags: ['pharmacy', 'retail'],
isActive: true,
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
expect(CreateStoreSchema.safeParse({ name: '' }).success).toBe(false);
});
it('rejects invalid location lat', () => {
expect(
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success,
).toBe(false);
});
it('rejects invalid location lng', () => {
expect(
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success,
).toBe(false);
});
it('trims name whitespace', () => {
const result = CreateStoreSchema.parse({ name: ' Rite Aid ' });
expect(result.name).toBe('Rite Aid');
});
});
describe('UpdateStoreSchema', () => {
it('accepts empty object', () => {
expect(UpdateStoreSchema.safeParse({}).success).toBe(true);
});
it('accepts partial update', () => {
const result = UpdateStoreSchema.safeParse({ isActive: false });
expect(result.success).toBe(true);
});
it('accepts tags update', () => {
const result = UpdateStoreSchema.safeParse({ tags: ['grocery'] });
expect(result.success).toBe(true);
});
});
describe('StoreQuerySchema', () => {
it('applies default limit', () => {
const result = StoreQuerySchema.parse({});
expect(result.limit).toBe(20);
});
it('coerces limit from string', () => {
const result = StoreQuerySchema.parse({ limit: '5' });
expect(result.limit).toBe(5);
});
it('accepts tags and search filters', () => {
const result = StoreQuerySchema.parse({ tags: 'pharmacy', search: 'walgreens' });
expect(result.tags).toBe('pharmacy');
expect(result.search).toBe('walgreens');
});
});

View file

@ -0,0 +1,60 @@
import { z } from 'zod/v4';
const locationSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
});
export const CreateStoreSchema = z.object({
name: z.string().min(1).max(200).trim(),
address: z.string().max(500).trim().optional(),
location: locationSchema.optional(),
url: z.url().optional(),
notes: z.string().max(1000).trim().optional(),
tags: z.array(z.string().min(1).max(50).trim()).default([]),
isActive: z.boolean().default(true),
});
export const UpdateStoreSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
address: z.string().max(500).trim().optional(),
location: locationSchema.optional(),
url: z.url().optional(),
notes: z.string().max(1000).trim().optional(),
tags: z.array(z.string().min(1).max(50).trim()).optional(),
isActive: z.boolean().optional(),
});
export const StoreQuerySchema = z.object({
tags: z.string().optional(),
search: z.string().max(200).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const StoreResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
name: z.string(),
address: z.string().optional(),
location: locationSchema.optional(),
url: z.string().optional(),
notes: z.string().optional(),
tags: z.array(z.string()),
isActive: z.boolean(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const StoreListResponseSchema = z.object({
data: z.array(StoreResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
}),
});
export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
export type StoreQueryInput = z.infer<typeof StoreQuerySchema>;