feat(shopping): add typed requestIngredient and migrate members to typed endpoint; fix decoders for stricter RecipeOut; tests green
This commit is contained in:
parent
290ad06f0c
commit
9cac6ca5ba
7 changed files with 61 additions and 17 deletions
|
|
@ -1,16 +1,15 @@
|
|||
import { api, fetchApi, getHouseholdSlug } from '@/api/client'
|
||||
import type { components, paths } from '@/api/types'
|
||||
import { api, getHouseholdSlug } from '@/api/client'
|
||||
import type { components } from '@/api/types'
|
||||
|
||||
export type Member = components['schemas']['User']
|
||||
export type Member = components['schemas']['HouseholdMember']
|
||||
|
||||
// Prefer typed endpoint if exists, fallback to raw fetch for now
|
||||
export async function listMembers(): Promise<Member[]> {
|
||||
// Temporary raw GET using household slug in query until typed endpoint is available
|
||||
const slug = getHouseholdSlug() || ''
|
||||
const path = slug ? `/api/v1/households/${encodeURIComponent(slug)}/members` : '/api/v1/households/members'
|
||||
const resp = await fetchApi(path, { method: 'GET' })
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
|
||||
const data = await resp.json().catch(() => null)
|
||||
const arr = Array.isArray(data) ? data : []
|
||||
return arr.filter((m): m is Member => typeof m === 'object' && m !== null && typeof (m as { id: unknown }).id === 'number')
|
||||
const householdSlug = getHouseholdSlug()
|
||||
if (!householdSlug) throw new Error('Missing household slug')
|
||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/members', {
|
||||
params: { path: { householdSlug } },
|
||||
})
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,5 +382,17 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
|
|||
if (!response.ok) throw httpError(response, error)
|
||||
}
|
||||
|
||||
export async function requestIngredient(ingredientId: number): Promise<import('@/domain/types').ListIngredientItemWithRefs> {
|
||||
const householdSlug = requireSlug()
|
||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
|
||||
params: { path: { householdSlug } },
|
||||
body: { ingredientId },
|
||||
})
|
||||
if (!response.ok) throw httpError(response, error)
|
||||
const [decoded] = decodeListIngredientItems([data as any])
|
||||
if (!decoded) throw new Error('Failed to decode requested ingredient item')
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Re-export domain command types for convenience at SDK surface
|
||||
export type { PurchaseRequest } from '@/domain/commands'
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ function createFromScratch() {
|
|||
ingredients: [],
|
||||
imageUrls: [],
|
||||
serves: 1,
|
||||
createdById: -1,
|
||||
createdBy: null,
|
||||
hiddenById: null,
|
||||
hiddenBy: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export function useShopping() {
|
|||
purchaseShoppingList: sdk.purchaseShoppingList,
|
||||
requestMeal: sdk.requestMeal,
|
||||
unrequestMeal: sdk.unrequestMeal,
|
||||
requestIngredient: sdk.requestIngredient,
|
||||
getMyShoppingList: sdk.getMyShoppingList,
|
||||
saveMyShoppingList: sdk.saveMyShoppingList,
|
||||
// View-model helpers
|
||||
|
|
|
|||
|
|
@ -28,9 +28,23 @@ export function decodeRecipe(
|
|||
): Recipe {
|
||||
if (!r) throw new Error('Invalid recipe payload')
|
||||
// Normalize arrays that may be optional
|
||||
const imageUrls = r.imageUrls ?? []
|
||||
const ingredients = r.ingredients ?? []
|
||||
return { ...r, imageUrls, ingredients }
|
||||
const imageUrls = (r as components['schemas']['RecipeOut']).imageUrls ?? (r as components['schemas']['Recipe']).imageUrls ?? []
|
||||
const ingredients = (r as components['schemas']['RecipeOut']).ingredients ?? (r as components['schemas']['Recipe']).ingredients ?? []
|
||||
// Normalize createdBy/hiddenBy to MemberRef shape when input is legacy Recipe (with Person)
|
||||
const createdByRaw = (r as any).createdBy
|
||||
const hiddenByRaw = (r as any).hiddenBy
|
||||
const toMemberRef = (v: any) => {
|
||||
if (!v) return v
|
||||
if (typeof v === 'object' && v) {
|
||||
const id = (v as any).id
|
||||
const displayName = 'displayName' in v ? (v as any).displayName : ('name' in v ? (v as any).name : undefined)
|
||||
return typeof id === 'number' && typeof displayName === 'string' ? { id, displayName } : v
|
||||
}
|
||||
return v
|
||||
}
|
||||
const createdBy = createdByRaw !== undefined ? toMemberRef(createdByRaw) : undefined
|
||||
const hiddenBy = hiddenByRaw !== undefined ? toMemberRef(hiddenByRaw) : undefined
|
||||
return { ...(r as any), imageUrls, ingredients, ...(createdBy !== undefined ? { createdBy } : {}), ...(hiddenBy !== undefined ? { hiddenBy } : {}) }
|
||||
}
|
||||
|
||||
export function decodeMeal(
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@
|
|||
v-for="m in members"
|
||||
:key="m.id"
|
||||
>
|
||||
{{ m.displayName }} <small>({{ m.email }})</small>
|
||||
{{ m.displayName }} <small>— role: {{ m.role }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="muted"
|
||||
>
|
||||
Listing members will be added once the backend endpoint is available.
|
||||
No members to show yet.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { server, http, HttpResponse } from './test-setup'
|
||||
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
|
||||
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList, requestIngredient } from '@/api/sdk'
|
||||
import { setHouseholdSlugProvider } from '@/api/client'
|
||||
|
||||
describe('shopping api (typed client)', () => {
|
||||
|
|
@ -33,4 +33,18 @@ describe('shopping api (typed client)', () => {
|
|||
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
|
||||
expect(list.id).toBe(1)
|
||||
})
|
||||
|
||||
it('requests an ingredient by id for the current list', async () => {
|
||||
server.use(
|
||||
http.post('*/api/v1/households/:householdSlug/shopping/current/ingredients', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
expect(body).toEqual({ ingredientId: 123 })
|
||||
return HttpResponse.json({ id: -1, ingredientId: 123, personId: 7, createdDate: new Date().toISOString(), kind: 'ingredient', listId: null, mealId: null, recipeId: null })
|
||||
})
|
||||
)
|
||||
setHouseholdSlugProvider(() => 'the-smiths')
|
||||
const item = await requestIngredient(123)
|
||||
expect(item.kind).toBe('ingredient')
|
||||
expect(item.ingredientId).toBe(123)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue