This commit is contained in:
jableader 2025-11-01 16:59:22 +11:00
parent 630c6dabcf
commit a6d729509e
10 changed files with 222 additions and 175 deletions

View file

@ -16,6 +16,11 @@ export function setHouseholdSlugProvider(provider: (() => string | null) | null)
householdSlugProvider = provider
}
// Expose current household slug for SDK convenience (temporary during migration)
export function getHouseholdSlug(): string | null {
return householdSlugProvider ? householdSlugProvider() : null
}
export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider
}

View file

@ -1,4 +1,4 @@
import { api } from '@/api/client'
import { api, getHouseholdSlug, fetchApi } from '@/api/client'
import type { components } from '@/api/types'
import {
toDate,
@ -33,6 +33,12 @@ function httpError(response: Response, error: unknown): Error {
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
}
function requireSlug(explicit?: string): string {
const slug = explicit ?? getHouseholdSlug() ?? null
if (!slug) throw new Error('Missing household slug')
return slug
}
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
// Shopping list mapped view types now come from domain/types
@ -156,52 +162,66 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit
}
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } })
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug }, query } })
if (!response.ok) throw httpError(response, error)
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
}
export async function getRecipe(id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } })
export async function getRecipe(householdSlug: string, id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes/{recipe_id}', {
params: { path: { householdSlug: requireSlug(householdSlug), recipe_id: Number(id) } },
})
if (!response.ok) throw httpError(response, error)
const mapped = decodeRecipe(data)
if (!mapped) throw new Error('Recipe not found')
return mapped
}
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } })
export async function saveRecipe(recipe: components['schemas']['RecipeCreate']): Promise<Recipe | null> {
const householdSlug = requireSlug()
// Map "Recipe-Input" to "RecipeCreate"
const body: components['schemas']['RecipeCreate'] = {
name: recipe.name,
link: recipe.link,
serves: recipe.serves,
imageUrls: recipe.imageUrls ?? [],
ingredients: recipe.ingredients ?? [],
}
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug } }, body })
if (!response.ok) throw httpError(response, error)
return decodeRecipe(data)
}
export async function deleteRecipe(id: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) }, cookie: { user_id: 0 } } })
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/recipes/{recipe_id}', { params: { path: { householdSlug, recipe_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
}
export async function parseRecipe(url: string): Promise<Recipe | null> {
const { data, error, response } = await api.GET('/api/v1/recipes/parse', { params: { query: { url }, cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' })
if (!res.ok) throw httpError(res, null)
const data = await res.json()
return decodeRecipe(data)
}
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', {
params: { query: { ingredients: lines } },
})
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v]))
const res = await fetchApi(url, { method: 'GET' })
if (!res.ok) throw httpError(res, null)
const data = await res.json()
return decodeIngredients(Array.isArray(data) ? data : [])
}
export async function parseProduct(
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): Promise<components['schemas']['Product'] | null> {
const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] }
const res = await api.POST('/api/v1/products', { body })
if (!res.response.ok) throw httpError(res.response, res.error)
return res.data ?? null
const body = { url, tags: [ingredient.name, ingredient.line] }
const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } })
if (!res.ok) throw httpError(res, null)
return (await res.json()) ?? null
}
// Persons
@ -212,8 +232,10 @@ async function listPersons(params?: { q?: string | null; cursor?: string | null;
if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit
}
const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } })
if (!response.ok) throw httpError(response, error)
const url = '/api/v1/persons' + (Object.keys(query).length ? ('?' + new URLSearchParams(query as Record<string, string>)) : '')
const res = await fetchApi(url, { method: 'GET' })
if (!res.ok) throw httpError(res, null)
const data = await res.json()
const normalized = Array.isArray(data) ? { items: data } : (data ?? null)
return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
}
@ -228,8 +250,9 @@ export async function searchPersons(name: string): Promise<Page<components['sche
// Meals
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
const { data, error, response } = await api.GET('/api/v1/meals/upcoming', {
params: { query: { from: from.toISOString(), to: to.toISOString() } },
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/upcoming', {
params: { path: { householdSlug }, query: { from: from.toISOString(), to: to.toISOString() } },
})
if (!response.ok) throw httpError(response, error)
const list = Array.isArray(data) ? data : []
@ -240,7 +263,8 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
}
export async function getMeal(id: number | string): Promise<Meal> {
const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } })
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
@ -249,23 +273,25 @@ export async function getMeal(id: number | string): Promise<Meal> {
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
const hasId = typeof meal.id === 'number' && meal.id >= 0
const householdSlug = requireSlug()
if (hasId) {
const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(meal.id) } },
const { data, error, response } = await api.PUT('/api/v1/households/{householdSlug}/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(meal.id) } },
body: meal,
})
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
} else {
const { data, error, response } = await api.POST('/api/v1/meals', { body: meal })
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals', { params: { path: { householdSlug } }, body: meal })
if (!response.ok) throw httpError(response, error)
return decodeMeal(data)
}
}
export async function markMealConsumed(mealId: number | string): Promise<Meal> {
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals/{meal_id}/consumed', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
})
if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data)
@ -274,37 +300,27 @@ export async function markMealConsumed(mealId: number | string): Promise<Meal> {
}
export async function deleteMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
})
if (!response.ok) throw httpError(response, error)
}
// Shopping
export async function getMyShoppingList(): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/shopping/current/me/ingredients', { params: { cookie: { user_id: 0 } } })
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
export async function saveMyShoppingList(items: components['schemas']['Ingredient'][]): Promise<Ingredient[]> {
const { data, error, response } = await api.POST('/api/v1/shopping/current/me/ingredients', {
body: items,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
return decodeIngredients(data ?? [])
}
// getMyShoppingList/saveMyShoppingList endpoints removed in v2; not used by UI currently
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } })
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } })
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null
}
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
const { data, error, response } = await api.GET('/api/v1/shopping/current')
const householdSlug = requireSlug()
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/current', { params: { path: { householdSlug } } })
if (!response.ok) throw httpError(response, error)
const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list')
@ -333,9 +349,10 @@ export async function purchaseShoppingList(
storeName: 'home',
items,
}
const { data, error, response } = await api.POST('/api/v1/shopping', {
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping', {
params: { path: { householdSlug } },
body,
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data)
@ -343,16 +360,18 @@ export async function purchaseShoppingList(
}
export async function requestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', {
const householdSlug = requireSlug()
const { error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/meals/me', {
params: { path: { householdSlug } },
body: { mealId: Number(mealId) },
params: { cookie: { user_id: 0 } },
})
if (!response.ok) throw httpError(response, error)
}
export async function unrequestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', {
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/meals/{meal_id}', {
params: { path: { householdSlug, meal_id: Number(mealId) } },
})
if (!response.ok) throw httpError(response, error)
}

View file

@ -393,7 +393,7 @@ export interface components {
};
/** Mealslookup */
mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"];
[key: string]: components["schemas"]["Meal"];
};
/** Shoppinglistlookup */
shoppingListLookup: {
@ -401,7 +401,7 @@ export interface components {
};
/** Recipeslookup */
recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"];
[key: string]: components["schemas"]["Recipe"];
};
};
/** HTTPValidationError */
@ -521,7 +521,7 @@ export interface components {
consumedDate?: string | null;
};
/** Meal */
"Meal-Input": {
Meal: {
/**
* Id
* @default -1
@ -541,34 +541,7 @@ export interface components {
/** Consumers */
consumers?: components["schemas"]["Person"][];
/** Recipes */
recipes?: components["schemas"]["MealRecipe-Input"][];
/** Extraingredients */
extraIngredients?: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
};
/** Meal */
"Meal-Output": {
/**
* 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"][];
recipes?: components["schemas"]["MealRecipe"][];
/** Extraingredients */
extraIngredients?: components["schemas"]["Ingredient"][];
/** Purchasedate */
@ -579,25 +552,89 @@ export interface components {
/** Mealid */
mealId: number;
};
/** MealRecipe */
"MealRecipe-Input": {
/** Mealid */
mealId: number;
/** Recipeid */
recipeId: number;
/** Servings */
servings: number;
recipe?: components["schemas"]["Recipe-Input"] | null;
/** MealIn */
MealIn: {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["MemberRef"][];
/** Cleanup */
cleanup: components["schemas"]["MemberRef"][];
/** Consumers */
consumers: components["schemas"]["MemberRef"][];
/**
* Recipes
* @default []
*/
recipes: components["schemas"]["MealRecipeIn"][];
/**
* Extraingredients
* @default []
*/
extraIngredients: components["schemas"]["Ingredient"][];
};
/** MealOut */
MealOut: {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["MemberRef"][];
/** Cleanup */
cleanup: components["schemas"]["MemberRef"][];
/** Consumers */
consumers: components["schemas"]["MemberRef"][];
/** Recipes */
recipes: components["schemas"]["MealRecipe"][];
/** Extraingredients */
extraIngredients: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
};
/** MealRecipe */
"MealRecipe-Output": {
MealRecipe: {
/** Mealid */
mealId: number;
/** Recipeid */
recipeId: number;
/** Servings */
servings: number;
recipe?: components["schemas"]["Recipe-Output"] | null;
recipe?: components["schemas"]["Recipe"] | null;
};
/** MealRecipeIn */
MealRecipeIn: {
/** Mealid */
mealId: number;
/** Recipeid */
recipeId: number;
/** Servings */
servings: number;
};
/** MemberRef */
MemberRef: {
/** Id */
id: number;
/** Displayname */
displayName: string;
};
/** Ok */
Ok: {
@ -685,7 +722,7 @@ export interface components {
list: components["schemas"]["ShoppingListOut"];
/** Mealslookup */
mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"];
[key: string]: components["schemas"]["Meal"];
};
/** Ingredientslookup */
ingredientsLookup: {
@ -693,44 +730,11 @@ export interface components {
};
/** Recipeslookup */
recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"];
[key: string]: components["schemas"]["Recipe"];
};
};
/** Recipe */
"Recipe-Input": {
/**
* 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;
};
/** Recipe */
"Recipe-Output": {
Recipe: {
/**
* Id
* @default -1
@ -1382,7 +1386,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"][];
"application/json": components["schemas"]["MealOut"][];
};
};
403: components["responses"]["Problem403"];
@ -1415,7 +1419,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"];
"application/json": components["schemas"]["MealOut"];
};
};
403: components["responses"]["Problem403"];
@ -1443,7 +1447,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["Meal-Input"];
"application/json": components["schemas"]["MealIn"];
};
};
responses: {
@ -1453,7 +1457,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"];
"application/json": components["schemas"]["MealOut"];
};
};
400: components["responses"]["Problem400"];
@ -1488,7 +1492,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"];
"application/json": components["schemas"]["MealOut"];
};
};
403: components["responses"]["Problem403"];
@ -1526,7 +1530,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"];
"application/json": components["schemas"]["MealOut"];
};
};
400: components["responses"]["Problem400"];
@ -1554,7 +1558,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["Meal-Input"];
"application/json": components["schemas"]["MealIn"];
};
};
responses: {
@ -1564,7 +1568,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meal-Output"];
"application/json": components["schemas"]["MealOut"];
};
};
400: components["responses"]["Problem400"];

View file

@ -213,16 +213,8 @@ function addPerson(list: PeopleKey, person: Person) {
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const r = await getRecipe(recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)
addPersonIfNotExists(meal.consumers, r.createdBy)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, r.createdBy)
}
}
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, recipe.id)
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
}
@ -231,7 +223,7 @@ async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) {
addIngredient()
} else {
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
}
}

View file

@ -75,7 +75,8 @@ import { useAlert } from '@/composables/useAlert'
import { parseQueryString } from '@/router/helpers'
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types'
import type { Recipe as DomainRecipe, Ingredient } from '@/domain/types'
import type { components } from '@/api/types'
const props = defineProps({
id: { type: String, required: false, default: undefined },
@ -108,7 +109,8 @@ function parseLink() {
async function refreshRecipe() {
const id = props.id ? parseInt(props.id) : null
if (id !== null && id >= 0) {
const r = await getRecipe(id)
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, id)
recipe.value = r
link.value = r.link ?? ''
return
@ -134,7 +136,7 @@ function deleteIngredient(ingredient: Ingredient) {
}
async function saveRecipe() {
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null
const saved = recipe.value ? await saveRecipeApi(toRecipeCreate(recipe.value)) : null
if (saved && saved.id >= 0) {
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
router.push(`/recipes/${saved.id}`)
@ -147,7 +149,6 @@ function createFromScratch() {
recipe.value = {
id: -1,
name: 'My new recipe',
createdById: -1,
link: '',
ingredients: [],
imageUrls: [],
@ -189,21 +190,13 @@ watch(
)
// expose functions for template binding names (automatic in <script setup>)
function toRecipeInput(r: DomainRecipe): RecipeInput {
function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate'] {
return {
id: r.id,
name: r.name,
link: r.link,
serves: r.serves,
imageUrls: r.imageUrls ?? [],
ingredients: r.ingredients ?? [],
basedOnRecipe: r.basedOnRecipe ?? null,
// let backend set created/hidden dates
createdById: r.createdById,
createdBy: r.createdBy ?? null,
// dateHidden omitted
hiddenById: r.hiddenById ?? null,
hiddenBy: r.hiddenBy ?? null,
}
}
</script>

View file

@ -1,31 +1,34 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('meals api (typed client)', () => {
it('gets a meal by id', async () => {
server.use(
http.get('*/api/v1/meals/:id', ({ params }) => {
http.get('*/api/v1/households/:householdSlug/meals/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await getMeal(10)
expect(meal.id).toBeDefined()
})
it('marks a meal consumed', async () => {
server.use(
http.post('*/api/v1/meals/:id/consumed', () => {
http.post('*/api/v1/households/:householdSlug/meals/:id/consumed', () => {
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await markMealConsumed(10)
expect(meal.consumedDate).toBeInstanceOf(Date)
})
it('lists upcoming meals', async () => {
server.use(
http.get('*/api/v1/meals/upcoming', ({ request }) => {
http.get('*/api/v1/households/:householdSlug/meals/upcoming', ({ request }) => {
const url = new URL(request.url)
if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
return new HttpResponse(null, { status: 400 })
@ -36,6 +39,7 @@ describe('meals api (typed client)', () => {
])
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
expect(Array.isArray(list)).toBe(true)
expect(list[0].suggestedDate).toBeInstanceOf(Date)

View file

@ -5,8 +5,8 @@ import { getRecipe } from '@/api/sdk'
describe('recipes api (errors)', () => {
it('throws on 404 getRecipe', async () => {
server.use(
http.get('*/api/v1/recipes/:id', () => new HttpResponse(null, { status: 404 }))
http.get('*/api/v1/households/:householdSlug/recipes/:id', () => new HttpResponse(null, { status: 404 }))
)
await expect(getRecipe('999')).rejects.toBeTruthy()
await expect(getRecipe('the-smiths', '999')).rejects.toBeTruthy()
})
})

View file

@ -1,13 +1,14 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Tests validate the typed client wrapper behavior without needing the backend
describe('recipes api (typed client)', () => {
it('gets a recipe by id', async () => {
server.use(
http.get('*/api/v1/recipes/:id', ({ params }) => {
http.get('*/api/v1/households/:householdSlug/recipes/:id', ({ params }) => {
// eslint-disable-next-line no-console
console.log('MSW handler hit with params:', params)
const { id } = params
@ -15,7 +16,8 @@ describe('recipes api (typed client)', () => {
})
)
const data = await getRecipe('123')
setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', '123')
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Drive migration to path-scoped recipes via householdSlug
describe('recipes api v2 (household-scoped)', () => {
it('gets a recipe by id with householdSlug path', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/recipes/:recipe_id', ({ params }) => {
const { householdSlug, recipe_id } = params
expect(householdSlug).toBe('the-smiths')
return HttpResponse.json({ id: Number(recipe_id), name: 'Pancakes', ingredients: [] }, { status: 200 })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', 123)
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})
})

View file

@ -1,30 +1,35 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('shopping api (typed client)', () => {
it('gets current shopping list', async () => {
server.use(http.get('*/api/v1/shopping/current', () => HttpResponse.json({ outstandingItems: [] })))
server.use(http.get('*/api/v1/households/:householdSlug/shopping/current', () => HttpResponse.json({ outstandingItems: [], requestedMeals: [], purchasedItems: [], ingredientsLookup: {}, mealsLookup: {}, shoppingListLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getCurrentShoppingList()
expect(list).toBeTruthy()
})
it('gets a purchased shopping list by id', async () => {
server.use(http.get('*/api/v1/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] } })))
server.use(http.get('*/api/v1/households/:householdSlug/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getShoppingList(99)
expect(list.id).toBe(99)
})
it('requests and unrequests a meal', async () => {
server.use(http.post('*/api/v1/shopping/current/meals/me', () => HttpResponse.json([{ id: 1 }])))
server.use(http.post('*/api/v1/households/:householdSlug/shopping/current/meals/me', () => HttpResponse.json({ id: 1, personId: 1, mealId: 44, createdDate: new Date().toISOString(), kind: 'requestedMeal' })))
setHouseholdSlugProvider(() => 'the-smiths')
await requestMeal(44)
server.use(http.delete('*/api/v1/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
server.use(http.delete('*/api/v1/households/:householdSlug/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
await unrequestMeal(44)
})
it('purchases a list', async () => {
server.use(http.post('*/api/v1/shopping', () => HttpResponse.json({ list: { id: 1, items: [] } })))
server.use(http.post('*/api/v1/households/:householdSlug/shopping', () => HttpResponse.json({ list: { id: 1, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
expect(list.id).toBe(1)
})