diff --git a/src/api/client.ts b/src/api/client.ts index c85f490..308d019 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 } diff --git a/src/api/sdk.ts b/src/api/sdk.ts index b7b13df..0669525 100644 --- a/src/api/sdk.ts +++ b/src/api/sdk.ts @@ -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 { - 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 { + 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 { - 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 { + 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 { - 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 { - 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 { - 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, url: string ): Promise { - 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)) : '') + 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(normalized, (p) => p) } @@ -228,8 +250,9 @@ export async function searchPersons(name: string): Promise { - 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 { } export async function getMeal(id: number | string): Promise { - 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 { export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise { 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 { - 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 { } export async function deleteMeal(mealId: number | string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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) } diff --git a/src/api/types.ts b/src/api/types.ts index e407863..8d21345 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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"]; diff --git a/src/components/meals/EditMealPage.vue b/src/components/meals/EditMealPage.vue index 4f98809..f70f64a 100644 --- a/src/components/meals/EditMealPage.vue +++ b/src/components/meals/EditMealPage.vue @@ -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 }) } @@ -230,8 +222,8 @@ async function selectRecipe(recipe: { id: number | string }) { async function onEditAdditionalIngredients(editing: boolean) { if (editing && meal.extraIngredients.length === 0) { addIngredient() - } else { - meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line) + } else { + meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line) } } diff --git a/src/components/recipes/EditRecipePage.vue b/src/components/recipes/EditRecipePage.vue index 69c392b..505204b 100644 --- a/src/components/recipes/EditRecipePage.vue +++ b/src/components/recipes/EditRecipePage.vue @@ -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 diff --git a/tests/meals.api.test.js b/tests/meals.api.test.js index 6deef6a..a37ee84 100644 --- a/tests/meals.api.test.js +++ b/tests/meals.api.test.js @@ -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) diff --git a/tests/recipes.api.errors.test.js b/tests/recipes.api.errors.test.js index 03b3e9a..f3c60d0 100644 --- a/tests/recipes.api.errors.test.js +++ b/tests/recipes.api.errors.test.js @@ -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() }) }) diff --git a/tests/recipes.api.test.js b/tests/recipes.api.test.js index 548f5fb..c5d0834 100644 --- a/tests/recipes.api.test.js +++ b/tests/recipes.api.test.js @@ -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') }) diff --git a/tests/recipes.api.v2.test.ts b/tests/recipes.api.v2.test.ts new file mode 100644 index 0000000..52f3c80 --- /dev/null +++ b/tests/recipes.api.v2.test.ts @@ -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') + }) +}) diff --git a/tests/shopping.api.test.js b/tests/shopping.api.test.js index 8d72d62..9c9c2e8 100644 --- a/tests/shopping.api.test.js +++ b/tests/shopping.api.test.js @@ -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) })