commit 594a14009096f7321ff92638e05cfa7c5ae6ca07
Author: jableader <jacobdunk@gmail.com>
Date: Sat Nov 1 12:20:33 2025 +1100
API update - avoid optional arrays
commit 85787384cfc24dc28953ebb4630fe7614cb0ead1
Author: jableader <jacobdunk@gmail.com>
Date: Sat Nov 1 10:14:51 2025 +1100
refactor(types): add NonNullableArrays and apply to Meal arrays; test: add shopping mapper boundary tests; all checks green
commit 1c50b7e23585cd4a34b5a691ffbf87d6d123f474
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 26 15:30:45 2025 +1100
Use discriminated unions and overloads
This commit is contained in:
parent
f2dee9ca30
commit
b08561c9bb
6 changed files with 174 additions and 56 deletions
|
|
@ -26,6 +26,7 @@ import type {
|
||||||
} from '@/domain/types'
|
} from '@/domain/types'
|
||||||
import { fromOpenApiPage, type Page } from '@/domain/pagination'
|
import { fromOpenApiPage, type Page } from '@/domain/pagination'
|
||||||
|
|
||||||
|
import type { PurchaseRequest } from '@/domain/commands'
|
||||||
function httpError(response: Response, error: unknown): Error {
|
function httpError(response: Response, error: unknown): Error {
|
||||||
if (error instanceof Error) return error
|
if (error instanceof Error) return error
|
||||||
if (typeof error === 'string') return new Error(error)
|
if (typeof error === 'string') return new Error(error)
|
||||||
|
|
@ -310,9 +311,7 @@ export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO>
|
||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
|
// PurchaseRequest comes from domain/commands
|
||||||
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
|
|
||||||
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
|
|
||||||
|
|
||||||
export async function purchaseShoppingList(
|
export async function purchaseShoppingList(
|
||||||
completedRequests: PurchaseRequest[]
|
completedRequests: PurchaseRequest[]
|
||||||
|
|
@ -330,7 +329,8 @@ export async function purchaseShoppingList(
|
||||||
if (items.length === 0) return null
|
if (items.length === 0) return null
|
||||||
|
|
||||||
const body: components['schemas']['PurchaseListIn'] = {
|
const body: components['schemas']['PurchaseListIn'] = {
|
||||||
storeName: '',
|
// Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths")
|
||||||
|
storeName: 'home',
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
const { data, error, response } = await api.POST('/api/v1/shopping', {
|
const { data, error, response } = await api.POST('/api/v1/shopping', {
|
||||||
|
|
@ -356,3 +356,6 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export domain command types for convenience at SDK surface
|
||||||
|
export type { PurchaseRequest } from '@/domain/commands'
|
||||||
|
|
|
||||||
115
src/api/types.ts
115
src/api/types.ts
|
|
@ -344,21 +344,21 @@ export interface components {
|
||||||
/** Requestedmeals */
|
/** Requestedmeals */
|
||||||
requestedMeals: components["schemas"]["RequestedMealItem"][];
|
requestedMeals: components["schemas"]["RequestedMealItem"][];
|
||||||
/** Purchaseditems */
|
/** Purchaseditems */
|
||||||
purchasedItems?: components["schemas"]["ListIngredientItem"][];
|
purchasedItems: components["schemas"]["ListIngredientItem"][];
|
||||||
/** Ingredientslookup */
|
/** Ingredientslookup */
|
||||||
ingredientsLookup?: {
|
ingredientsLookup: {
|
||||||
[key: string]: components["schemas"]["Ingredient"];
|
[key: string]: components["schemas"]["Ingredient"];
|
||||||
};
|
};
|
||||||
/** Mealslookup */
|
/** Mealslookup */
|
||||||
mealsLookup?: {
|
mealsLookup: {
|
||||||
[key: string]: components["schemas"]["Meal-Output"];
|
[key: string]: components["schemas"]["Meal-Output"];
|
||||||
};
|
};
|
||||||
/** Shoppinglistlookup */
|
/** Shoppinglistlookup */
|
||||||
shoppingListLookup?: {
|
shoppingListLookup: {
|
||||||
[key: string]: components["schemas"]["ShoppingListOut"];
|
[key: string]: components["schemas"]["ShoppingListOut"];
|
||||||
};
|
};
|
||||||
/** Recipeslookup */
|
/** Recipeslookup */
|
||||||
recipesLookup?: {
|
recipesLookup: {
|
||||||
[key: string]: components["schemas"]["Recipe-Output"];
|
[key: string]: components["schemas"]["Recipe-Output"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -511,6 +511,33 @@ export interface components {
|
||||||
/** Mealid */
|
/** Mealid */
|
||||||
mealId: number;
|
mealId: number;
|
||||||
};
|
};
|
||||||
|
/** MealOut */
|
||||||
|
MealOut: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* @default -1
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Suggesteddate
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
suggestedDate: string;
|
||||||
|
/** Consumeddate */
|
||||||
|
consumedDate?: string | null;
|
||||||
|
/** Chefs */
|
||||||
|
chefs: components["schemas"]["Person"][];
|
||||||
|
/** Cleanup */
|
||||||
|
cleanup: components["schemas"]["Person"][];
|
||||||
|
/** Consumers */
|
||||||
|
consumers: components["schemas"]["Person"][];
|
||||||
|
/** Recipes */
|
||||||
|
recipes: components["schemas"]["MealRecipe-Output"][];
|
||||||
|
/** Extraingredients */
|
||||||
|
extraIngredients: components["schemas"]["Ingredient"][];
|
||||||
|
/** Purchasedate */
|
||||||
|
purchaseDate?: string | null;
|
||||||
|
};
|
||||||
/** MealRecipe */
|
/** MealRecipe */
|
||||||
"MealRecipe-Input": {
|
"MealRecipe-Input": {
|
||||||
/** Mealid */
|
/** Mealid */
|
||||||
|
|
@ -554,10 +581,10 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
/** Page[Recipe] */
|
/** Page[RecipeOut] */
|
||||||
Page_Recipe_: {
|
Page_RecipeOut_: {
|
||||||
/** Items */
|
/** Items */
|
||||||
items: components["schemas"]["Recipe-Output"][];
|
items: components["schemas"]["RecipeOut"][];
|
||||||
/** Nextcursor */
|
/** Nextcursor */
|
||||||
nextCursor?: string | null;
|
nextCursor?: string | null;
|
||||||
/** Prevcursor */
|
/** Prevcursor */
|
||||||
|
|
@ -630,7 +657,7 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** PurchaseListIn */
|
/** PurchaseListIn */
|
||||||
PurchaseListIn: {
|
PurchaseListIn: {
|
||||||
storeName: components["schemas"]["StoreEnum"];
|
storeName: components["schemas"]["StoreNameOut"];
|
||||||
/** Items */
|
/** Items */
|
||||||
items: components["schemas"]["IngredientPurchaseItemIn"][];
|
items: components["schemas"]["IngredientPurchaseItemIn"][];
|
||||||
};
|
};
|
||||||
|
|
@ -638,15 +665,15 @@ export interface components {
|
||||||
PurchasedShoppingList: {
|
PurchasedShoppingList: {
|
||||||
list: components["schemas"]["ShoppingListOut"];
|
list: components["schemas"]["ShoppingListOut"];
|
||||||
/** Mealslookup */
|
/** Mealslookup */
|
||||||
mealsLookup?: {
|
mealsLookup: {
|
||||||
[key: string]: components["schemas"]["Meal-Output"];
|
[key: string]: components["schemas"]["Meal-Output"];
|
||||||
};
|
};
|
||||||
/** Ingredientslookup */
|
/** Ingredientslookup */
|
||||||
ingredientsLookup?: {
|
ingredientsLookup: {
|
||||||
[key: string]: components["schemas"]["Ingredient"];
|
[key: string]: components["schemas"]["Ingredient"];
|
||||||
};
|
};
|
||||||
/** Recipeslookup */
|
/** Recipeslookup */
|
||||||
recipesLookup?: {
|
recipesLookup: {
|
||||||
[key: string]: components["schemas"]["Recipe-Output"];
|
[key: string]: components["schemas"]["Recipe-Output"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -716,6 +743,39 @@ export interface components {
|
||||||
hiddenById?: number | null;
|
hiddenById?: number | null;
|
||||||
hiddenBy?: components["schemas"]["Person"] | null;
|
hiddenBy?: components["schemas"]["Person"] | null;
|
||||||
};
|
};
|
||||||
|
/** RecipeOut */
|
||||||
|
RecipeOut: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* @default -1
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/** Link */
|
||||||
|
link: string;
|
||||||
|
/** Serves */
|
||||||
|
serves: number;
|
||||||
|
/** Imageurls */
|
||||||
|
imageUrls: string[];
|
||||||
|
/** Ingredients */
|
||||||
|
ingredients: components["schemas"]["Ingredient"][];
|
||||||
|
/** Basedonrecipe */
|
||||||
|
basedOnRecipe?: number | null;
|
||||||
|
/**
|
||||||
|
* Datecreated
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
dateCreated: string;
|
||||||
|
/** Createdbyid */
|
||||||
|
createdById: number;
|
||||||
|
createdBy?: components["schemas"]["Person"] | null;
|
||||||
|
/** Datehidden */
|
||||||
|
dateHidden?: string | null;
|
||||||
|
/** Hiddenbyid */
|
||||||
|
hiddenById?: number | null;
|
||||||
|
hiddenBy?: components["schemas"]["Person"] | null;
|
||||||
|
};
|
||||||
/** RequestedMealItem */
|
/** RequestedMealItem */
|
||||||
RequestedMealItem: {
|
RequestedMealItem: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -749,12 +809,16 @@ export interface components {
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
*/
|
*/
|
||||||
createdDate: string;
|
createdDate: string;
|
||||||
storeName: components["schemas"]["StoreEnum"];
|
/**
|
||||||
|
* Storename
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
storeName: "woolworths" | "coles" | "home";
|
||||||
/** Purchasedbyid */
|
/** Purchasedbyid */
|
||||||
purchasedById: number;
|
purchasedById: number;
|
||||||
purchasedBy?: components["schemas"]["Person"] | null;
|
purchasedBy?: components["schemas"]["Person"] | null;
|
||||||
/** Items */
|
/** Items */
|
||||||
items?: components["schemas"]["ListIngredientItem"][];
|
items: components["schemas"]["ListIngredientItem"][];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* StoreEnum
|
* StoreEnum
|
||||||
|
|
@ -770,6 +834,11 @@ export interface components {
|
||||||
/** Error Type */
|
/** Error Type */
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* StoreNameOut
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
StoreNameOut: "woolworths" | "coles" | "home";
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Bad Request */
|
/** @description Bad Request */
|
||||||
|
|
@ -862,7 +931,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Recipe-Output"];
|
"application/json": components["schemas"]["RecipeOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["Problem400"];
|
400: components["responses"]["Problem400"];
|
||||||
|
|
@ -947,7 +1016,7 @@ export interface operations {
|
||||||
* "total": 1
|
* "total": 1
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
"application/json": components["schemas"]["Page_Recipe_"];
|
"application/json": components["schemas"]["Page_RecipeOut_"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @description Validation Error */
|
||||||
|
|
@ -982,7 +1051,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Recipe-Output"];
|
"application/json": components["schemas"]["RecipeOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["Problem400"];
|
400: components["responses"]["Problem400"];
|
||||||
|
|
@ -1014,7 +1083,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Recipe-Output"];
|
"application/json": components["schemas"]["RecipeOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
404: components["responses"]["Problem404"];
|
404: components["responses"]["Problem404"];
|
||||||
|
|
@ -1112,7 +1181,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Meal-Output"];
|
"application/json": components["schemas"]["MealOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
404: components["responses"]["Problem404"];
|
404: components["responses"]["Problem404"];
|
||||||
|
|
@ -1148,7 +1217,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Meal-Output"];
|
"application/json": components["schemas"]["MealOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["Problem400"];
|
400: components["responses"]["Problem400"];
|
||||||
|
|
@ -1183,7 +1252,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Meal-Output"];
|
"application/json": components["schemas"]["MealOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
404: components["responses"]["Problem404"];
|
404: components["responses"]["Problem404"];
|
||||||
|
|
@ -1217,7 +1286,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Meal-Output"];
|
"application/json": components["schemas"]["MealOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["Problem400"];
|
400: components["responses"]["Problem400"];
|
||||||
|
|
@ -1253,7 +1322,7 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["Meal-Output"];
|
"application/json": components["schemas"]["MealOut"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
400: components["responses"]["Problem400"];
|
400: components["responses"]["Problem400"];
|
||||||
|
|
|
||||||
5
src/domain/commands.ts
Normal file
5
src/domain/commands.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Domain command types: stable UI intents, translated to OpenAPI at the SDK boundary
|
||||||
|
|
||||||
|
export type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
|
||||||
|
export type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
|
||||||
|
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
|
||||||
|
|
@ -1,19 +1,4 @@
|
||||||
import type {
|
import type { Recipe, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput, ListIngredientItem, RequestedMealItem, ListIngredientItemWithRefs, RequestedMealItemWithRefs } from './types'
|
||||||
RecipeOut,
|
|
||||||
Recipe,
|
|
||||||
MealOut,
|
|
||||||
Meal,
|
|
||||||
MealRecipe,
|
|
||||||
Ingredient as DomainIngredient,
|
|
||||||
ShoppingList,
|
|
||||||
ShoppingListItem,
|
|
||||||
ShoppingListItemWithRefs,
|
|
||||||
MealInput,
|
|
||||||
ListIngredientItem,
|
|
||||||
RequestedMealItem,
|
|
||||||
ListIngredientItemWithRefs,
|
|
||||||
RequestedMealItemWithRefs,
|
|
||||||
} from './types'
|
|
||||||
import type { components } from '@/api/types'
|
import type { components } from '@/api/types'
|
||||||
|
|
||||||
export function toDate(value: string | Date | null | undefined): Date | null {
|
export function toDate(value: string | Date | null | undefined): Date | null {
|
||||||
|
|
@ -38,16 +23,25 @@ export function decodeLookup<TIn, TOut>(
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeRecipe(r: RecipeOut | null | undefined): Recipe {
|
export function decodeRecipe(
|
||||||
|
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined
|
||||||
|
): Recipe {
|
||||||
if (!r) throw new Error('Invalid recipe payload')
|
if (!r) throw new Error('Invalid recipe payload')
|
||||||
|
// Normalize arrays that may be optional in legacy Recipe-Output
|
||||||
|
const imageUrls = r.imageUrls ?? []
|
||||||
|
const ingredients = r.ingredients ?? []
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
imageUrls,
|
||||||
|
ingredients,
|
||||||
dateCreated: toDate(r.dateCreated),
|
dateCreated: toDate(r.dateCreated),
|
||||||
dateHidden: toDate(r.dateHidden),
|
dateHidden: toDate(r.dateHidden),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeMeal(m: MealOut | null | undefined): Meal {
|
export function decodeMeal(
|
||||||
|
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
|
||||||
|
): Meal {
|
||||||
if (!m) throw new Error('Invalid meal payload')
|
if (!m) throw new Error('Invalid meal payload')
|
||||||
const recipes = Array.isArray(m.recipes)
|
const recipes = Array.isArray(m.recipes)
|
||||||
? m.recipes.map((mr) => decodeMealRecipe(mr))
|
? m.recipes.map((mr) => decodeMealRecipe(mr))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import type { components } from '@/api/types'
|
||||||
// Utility mapped types
|
// Utility mapped types
|
||||||
export type Replace<T, M> = Omit<T, keyof M> & M
|
export type Replace<T, M> = Omit<T, keyof M> & M
|
||||||
export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }>
|
export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }>
|
||||||
|
// Convert selected array keys to their non-nullable array counterparts
|
||||||
|
export type NonNullableArrays<T, K extends keyof T> = Replace<T, { [P in K]: NonNullable<T[P]> extends Array<infer U> ? U[] : T[P] }>
|
||||||
|
// Make selected keys required (non-optional) on a type
|
||||||
|
export type RequiredKeys<T, K extends keyof T> = Replace<T, { [P in K]-?: NonNullable<T[P]> }>
|
||||||
|
|
||||||
// Common helpers (intentionally minimal to avoid unused exports)
|
// Common helpers (intentionally minimal to avoid unused exports)
|
||||||
export type Lookup<T> = Record<string, T>
|
export type Lookup<T> = Record<string, T>
|
||||||
|
|
@ -10,8 +14,8 @@ export type Lookup<T> = Record<string, T>
|
||||||
export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
|
export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
|
||||||
|
|
||||||
// Domain type aliases
|
// Domain type aliases
|
||||||
export type RecipeOut = components['schemas']['Recipe-Output']
|
export type RecipeOut = components['schemas']['RecipeOut']
|
||||||
export type MealOut = components['schemas']['Meal-Output']
|
export type MealOut = components['schemas']['MealOut']
|
||||||
export type Ingredient = components['schemas']['Ingredient']
|
export type Ingredient = components['schemas']['Ingredient']
|
||||||
export type Product = components['schemas']['Product']
|
export type Product = components['schemas']['Product']
|
||||||
export type Person = components['schemas']['Person']
|
export type Person = components['schemas']['Person']
|
||||||
|
|
@ -27,15 +31,10 @@ export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
|
||||||
|
|
||||||
// Meal with decoded dates and nested MealRecipe with decoded recipe dates
|
// Meal with decoded dates and nested MealRecipe with decoded recipe dates
|
||||||
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
|
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
|
||||||
export type Meal = Replace<
|
type MealBase = Replace<WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, { recipes: MealRecipe[] }>
|
||||||
WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>,
|
export type Meal = RequiredKeys<
|
||||||
{
|
NonNullableArrays<MealBase, 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'>,
|
||||||
recipes: MealRecipe[]
|
'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'
|
||||||
chefs: Person[]
|
|
||||||
consumers: Person[]
|
|
||||||
cleanup: Person[]
|
|
||||||
extraIngredients: Ingredient[]
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
// Shopping domain shapes with dates normalized
|
// Shopping domain shapes with dates normalized
|
||||||
|
|
|
||||||
48
tests/shopping.mappers.boundary.test.ts
Normal file
48
tests/shopping.mappers.boundary.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
// These tests lock boundary behavior for partial/missing lookups and date normalization
|
||||||
|
|
||||||
|
describe('shopping mappers boundary', () => {
|
||||||
|
it('handles missing lookups gracefully (no refs attached)', () => {
|
||||||
|
const dto = {
|
||||||
|
outstandingItems: [
|
||||||
|
{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' },
|
||||||
|
],
|
||||||
|
requestedMeals: [
|
||||||
|
{ mealId: 2, createdDate: '2025-01-02T00:00:00Z' },
|
||||||
|
],
|
||||||
|
purchasedItems: [],
|
||||||
|
}
|
||||||
|
const mapped = mapCurrentShoppingList(dto as any)
|
||||||
|
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
|
||||||
|
expect(mapped.outstandingItems[0].meal).toBeUndefined()
|
||||||
|
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
|
||||||
|
expect(mapped.outstandingItems[0].list).toBeUndefined()
|
||||||
|
expect(mapped.requestedMeals[0].meal).toBeUndefined()
|
||||||
|
// Dates normalized
|
||||||
|
expect(mapped.outstandingItems[0].createdDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.requestedMeals[0].createdDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes dates on purchased list and items even with partial lookups', () => {
|
||||||
|
const dto = {
|
||||||
|
ingredientsLookup: { 10: { id: 10, name: 'Milk' } },
|
||||||
|
list: {
|
||||||
|
id: 11,
|
||||||
|
createdDate: '2025-03-03T00:00:00Z',
|
||||||
|
items: [
|
||||||
|
{ ingredientId: 10, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
|
||||||
|
{ ingredientId: 99, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const mapped = mapPurchasedShoppingList(dto as any)
|
||||||
|
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)
|
||||||
|
// First item gets ingredient ref, second does not
|
||||||
|
expect(mapped.list.items[0].ingredient?.name).toBe('Milk')
|
||||||
|
expect(mapped.list.items[1].ingredient).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue