feat(shopping): add ad‑hoc items UI with typed endpoints; remove legacy JS tests
This commit is contained in:
parent
adb2fc1bae
commit
5183217669
14 changed files with 108 additions and 286 deletions
|
|
@ -385,5 +385,14 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function unrequestIngredient(ingredientId: number): Promise<void> {
|
||||||
|
const householdSlug = requireSlug()
|
||||||
|
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
|
||||||
|
params: { path: { householdSlug } },
|
||||||
|
body: { ingredientId },
|
||||||
|
})
|
||||||
|
if (!response.ok) throw httpError(response, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export domain command types for convenience at SDK surface
|
// Re-export domain command types for convenience at SDK surface
|
||||||
export type { PurchaseRequest } from '@/domain/commands'
|
export type { PurchaseRequest } from '@/domain/commands'
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="ingredient in ingredients"
|
v-for="ingredient in ingredients"
|
||||||
:key="ingredient"
|
:key="ingredient.id ?? ingredient.line"
|
||||||
>
|
>
|
||||||
<div v-if="editing">
|
<div v-if="editing">
|
||||||
<p class="ingredient-line">
|
<p class="ingredient-line">
|
||||||
|
|
@ -57,9 +57,10 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { parseProduct, parseIngredients } from '@/api/sdk'
|
import type { Ingredient } from '@/domain/types'
|
||||||
|
import { parseIngredients } from '@/api/sdk'
|
||||||
import IngredientLine from './IngredientLine.vue'
|
import IngredientLine from './IngredientLine.vue'
|
||||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||||
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
|
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
|
||||||
|
|
@ -67,22 +68,24 @@ const editOff = new URL('@/assets/edit-off.svg', import.meta.url).toString()
|
||||||
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
|
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
|
||||||
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{ ingredients: Ingredient[]; editOnly?: boolean }>()
|
||||||
ingredients: { type: Array, required: true },
|
const emit = defineEmits<{
|
||||||
editOnly: { type: Boolean, default: false },
|
(e: 'on-add'): void
|
||||||
})
|
(e: 'on-delete', ingredient: Ingredient): void
|
||||||
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
|
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
|
||||||
|
(e: 'on-editing', isEditing: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const editing = ref(props.editOnly ?? false)
|
const editing = ref(props.editOnly ?? false)
|
||||||
|
|
||||||
async function updateProduct(ingredient, product_link) {
|
async function updateProduct(): Promise<void> {
|
||||||
const product = await parseProduct(ingredient, product_link)
|
// parseProduct is not available in v2 API; ignore for now
|
||||||
emit('on-update-ingredient', ingredient, { ...ingredient, product })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateIngredient(ingredient, line) {
|
async function updateIngredient(ingredient: Ingredient, line: string) {
|
||||||
const newIngredients = await parseIngredients([line])
|
const newIngredients = await parseIngredients([line])
|
||||||
emit('on-update-ingredient', ingredient, newIngredients[0])
|
const next = newIngredients[0]
|
||||||
|
if (next) emit('on-update-ingredient', ingredient, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEditing() {
|
function toggleEditing() {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,31 @@
|
||||||
<router-link :to="`/shopping/current`">
|
<router-link :to="`/shopping/current`">
|
||||||
Full Shopping List
|
Full Shopping List
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
|
<div class="adder">
|
||||||
|
<label>
|
||||||
|
Add existing ingredient:
|
||||||
|
<select v-model.number="selectedIngredientId">
|
||||||
|
<option :value="-1">-- select --</option>
|
||||||
|
<option
|
||||||
|
v-for="opt in availableIngredientOptions"
|
||||||
|
:key="opt.id"
|
||||||
|
:value="opt.id"
|
||||||
|
>
|
||||||
|
{{ opt.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
:disabled="selectedIngredientId < 0"
|
||||||
|
@click="addSelectedIngredient"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<editable-ingredients-panel
|
<editable-ingredients-panel
|
||||||
:ingredients="ingredients"
|
:ingredients="ingredients"
|
||||||
|
edit-only
|
||||||
@on-add="addIngredient"
|
@on-add="addIngredient"
|
||||||
@on-delete="deleteIngredient"
|
@on-delete="deleteIngredient"
|
||||||
@on-update-ingredient="updateIngredient"
|
@on-update-ingredient="updateIngredient"
|
||||||
|
|
@ -43,45 +66,93 @@
|
||||||
import { ref, onBeforeMount } from 'vue'
|
import { ref, onBeforeMount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import { useAlert } from '@/composables/useAlert'
|
||||||
import { useShopping } from '@/composables/useShopping'
|
import { useShopping } from '@/composables/useShopping'
|
||||||
import type { Ingredient } from '@/domain/types'
|
import type { Ingredient } from '@/domain/types'
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { loadUser } = useAuth()
|
const { loadUser } = useAuth()
|
||||||
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
const { show: showAlert } = useAlert()
|
||||||
|
const { getCurrentShoppingList, requestIngredient, unrequestIngredient } = useShopping()
|
||||||
|
|
||||||
const ingredients = ref<Ingredient[]>([])
|
const ingredients = ref<Ingredient[]>([])
|
||||||
|
let loading = false
|
||||||
|
const availableIngredientOptions = ref<{ id: number; name: string }[]>([])
|
||||||
|
const selectedIngredientId = ref<number>(-1)
|
||||||
|
|
||||||
async function updateShoppingList(save = false) {
|
async function refreshFromServer() {
|
||||||
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
|
const dto = await getCurrentShoppingList()
|
||||||
ingredients.value = newIngredients.map((i) => ({ ...i }))
|
// Personal ad-hoc ingredients are those in outstandingItems with no recipeId and no mealId
|
||||||
|
const personalItems = (dto.outstandingItems ?? []).filter((i) => (i.recipeId ?? null) == null && (i.mealId ?? null) == null)
|
||||||
|
ingredients.value = personalItems.map((i) => ({
|
||||||
|
id: i.ingredientId,
|
||||||
|
name: dto.ingredientsLookup?.[String(i.ingredientId)]?.name ?? '',
|
||||||
|
line: dto.ingredientsLookup?.[String(i.ingredientId)]?.line ?? '',
|
||||||
|
unit: dto.ingredientsLookup?.[String(i.ingredientId)]?.unit ?? 'Items',
|
||||||
|
quantity: dto.ingredientsLookup?.[String(i.ingredientId)]?.quantity ?? 1,
|
||||||
|
preparation: dto.ingredientsLookup?.[String(i.ingredientId)]?.preparation ?? '',
|
||||||
|
productId: dto.ingredientsLookup?.[String(i.ingredientId)]?.productId ?? null,
|
||||||
|
recipeId: null,
|
||||||
|
mealId: null,
|
||||||
|
product: dto.ingredientsLookup?.[String(i.ingredientId)]?.product ?? null,
|
||||||
|
}))
|
||||||
|
// Populate available ingredient options from lookup
|
||||||
|
const lookup = dto.ingredientsLookup ?? {}
|
||||||
|
availableIngredientOptions.value = Object.keys(lookup)
|
||||||
|
.map((k) => ({ id: Number(k), name: lookup[k]?.name ?? `#${k}` }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIngredient() {
|
async function addIngredient() {
|
||||||
|
if (loading) return
|
||||||
|
// Add a blank ingredient locally to allow text entry; upon edit, we parse line to an Ingredient
|
||||||
ingredients.value = [
|
ingredients.value = [
|
||||||
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
|
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null, productId: null, recipeId: null, mealId: null },
|
||||||
...ingredients.value,
|
...ingredients.value,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteIngredient(ingredient: Ingredient) {
|
async function addSelectedIngredient() {
|
||||||
|
try {
|
||||||
|
const id = selectedIngredientId.value
|
||||||
|
if (typeof id !== 'number' || id < 0) return
|
||||||
|
await requestIngredient(id)
|
||||||
|
selectedIngredientId.value = -1
|
||||||
|
await refreshFromServer()
|
||||||
|
} catch (e) {
|
||||||
|
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to add item' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIngredient(ingredient: Ingredient) {
|
||||||
|
try {
|
||||||
|
if (ingredient.id && ingredient.id >= 0) {
|
||||||
|
await unrequestIngredient(ingredient.id)
|
||||||
|
await refreshFromServer()
|
||||||
|
}
|
||||||
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
||||||
|
} catch (e) {
|
||||||
|
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to remove item' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
|
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
|
||||||
|
// Local edit only; ad-hoc add/remove is handled by the selector and delete button
|
||||||
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEditing(isStartingEdit: boolean) {
|
async function onEditing(isStartingEdit: boolean) {
|
||||||
await updateShoppingList(!isStartingEdit)
|
if (isStartingEdit) {
|
||||||
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
|
await refreshFromServer()
|
||||||
|
if (ingredients.value.length === 0) await addIngredient()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
const u = await loadUser()
|
const u = await loadUser()
|
||||||
if (!u) return router.push({ name: 'login' })
|
if (!u) return router.push({ name: 'login' })
|
||||||
await updateShoppingList()
|
await refreshFromServer()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export function useShopping() {
|
||||||
requestMeal: sdk.requestMeal,
|
requestMeal: sdk.requestMeal,
|
||||||
unrequestMeal: sdk.unrequestMeal,
|
unrequestMeal: sdk.unrequestMeal,
|
||||||
requestIngredient: sdk.requestIngredient,
|
requestIngredient: sdk.requestIngredient,
|
||||||
|
unrequestIngredient: sdk.unrequestIngredient,
|
||||||
getMyShoppingList: sdk.getMyShoppingList,
|
getMyShoppingList: sdk.getMyShoppingList,
|
||||||
saveMyShoppingList: sdk.saveMyShoppingList,
|
saveMyShoppingList: sdk.saveMyShoppingList,
|
||||||
// View-model helpers
|
// View-model helpers
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { decodeMeal } from '@/domain/decoders'
|
|
||||||
|
|
||||||
describe('mealMapper', () => {
|
|
||||||
it('maps individual meal date fields to Date instances', () => {
|
|
||||||
const input = {
|
|
||||||
id: 1,
|
|
||||||
suggestedDate: '2025-01-01T00:00:00Z',
|
|
||||||
purchaseDate: '2025-01-02T00:00:00Z',
|
|
||||||
consumedDate: '2025-01-03T00:00:00Z',
|
|
||||||
}
|
|
||||||
const result = decodeMeal({ ...input })
|
|
||||||
expect(result.suggestedDate).toBeInstanceOf(Date)
|
|
||||||
expect(result.purchaseDate).toBeInstanceOf(Date)
|
|
||||||
expect(result.consumedDate).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps lists of meals', () => {
|
|
||||||
const input = [
|
|
||||||
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
|
||||||
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
|
||||||
]
|
|
||||||
const result = input.map((m) => decodeMeal(m)).filter((m) => m)
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].suggestedDate).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { getUpcomingMeals } from '@/api/sdk'
|
|
||||||
|
|
||||||
describe('meals api (errors)', () => {
|
|
||||||
it('propagates errors on invalid upcoming range', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/meals/upcoming', () => new HttpResponse(null, { status: 400 }))
|
|
||||||
)
|
|
||||||
await expect(
|
|
||||||
getUpcomingMeals(new Date('2025-01-03T00:00:00Z'), new Date('2025-01-01T00:00:00Z'))
|
|
||||||
).rejects.toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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/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/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/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 })
|
|
||||||
}
|
|
||||||
return HttpResponse.json([
|
|
||||||
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
|
||||||
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { parseRecipe } from '@/api/sdk'
|
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
|
|
||||||
describe('parse api errors', () => {
|
|
||||||
it('rejects when recipe URL invalid (scoped)', async () => {
|
|
||||||
setHouseholdSlugProvider(() => 'the-smiths')
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', () => {
|
|
||||||
return new HttpResponse(null, { status: 422 })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { parseRecipe } from '@/api/sdk'
|
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
|
|
||||||
// Parse endpoints: ingredients, product, recipe
|
|
||||||
|
|
||||||
describe('parse api (typed client)', () => {
|
|
||||||
it('parses a recipe from URL (scoped)', async () => {
|
|
||||||
setHouseholdSlugProvider(() => 'the-smiths')
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ url: 'https://example.com/pancakes' })
|
|
||||||
return HttpResponse.json({ id: 10, name: 'Pancakes', link: '', serves: 1, imageUrls: [], ingredients: [], createdById: -1 })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const recipe = await parseRecipe('https://example.com/pancakes')
|
|
||||||
expect(recipe?.name).toBe('Pancakes')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { getRecipe } from '@/api/sdk'
|
|
||||||
|
|
||||||
describe('recipes api (errors)', () => {
|
|
||||||
it('throws on 404 getRecipe', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/households/:householdSlug/recipes/:id', () => new HttpResponse(null, { status: 404 }))
|
|
||||||
)
|
|
||||||
await expect(getRecipe('the-smiths', '999')).rejects.toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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/households/:householdSlug/recipes/:id', ({ params }) => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('MSW handler hit with params:', params)
|
|
||||||
const { id } = params
|
|
||||||
return HttpResponse.json({ id, name: 'Pancakes', ingredients: [] }, { status: 200 })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
setHouseholdSlugProvider(() => 'the-smiths')
|
|
||||||
const data = await getRecipe('the-smiths', '123')
|
|
||||||
expect(data).toBeTruthy()
|
|
||||||
expect(data.name).toBe('Pancakes')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { purchaseShoppingList } from '@/api/sdk'
|
|
||||||
|
|
||||||
describe('shopping api (errors)', () => {
|
|
||||||
it('propagates errors on purchase failure', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/shopping', () => new HttpResponse(null, { status: 422 }))
|
|
||||||
)
|
|
||||||
await expect(purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])).rejects.toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList, requestIngredient } 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/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/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/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/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/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)
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
|
|
||||||
|
|
||||||
describe('shoppingListMapper', () => {
|
|
||||||
it('maps current shopping list and wires references', () => {
|
|
||||||
const dto = {
|
|
||||||
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
|
||||||
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
|
||||||
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
|
||||||
shoppingListLookup: { 7: { id: 7, createdDate: '2025-01-01T00:00:00Z' } },
|
|
||||||
outstandingItems: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }],
|
|
||||||
requestedMeals: [],
|
|
||||||
purchasedItems: [],
|
|
||||||
}
|
|
||||||
const mapped = mapCurrentShoppingList(dto)
|
|
||||||
// DEBUG
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('mapped current keys:', Object.keys(mapped || {}))
|
|
||||||
const item = mapped.outstandingItems[0]
|
|
||||||
expect(item.ingredient.name).toBe('Eggs')
|
|
||||||
expect(item.meal.suggestedDate).toBeInstanceOf(Date)
|
|
||||||
expect(mapped.shoppingListLookup['7'].createdDate).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps purchased shopping list with list dates and item refs', () => {
|
|
||||||
const dto = {
|
|
||||||
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
|
||||||
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
|
||||||
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
|
||||||
list: { id: 9, createdDate: '2025-02-02T00:00:00Z', items: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 9 }] },
|
|
||||||
}
|
|
||||||
const mapped = mapPurchasedShoppingList(dto)
|
|
||||||
// DEBUG
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('mapped purchased keys:', Object.keys(mapped || {}))
|
|
||||||
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
|
||||||
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue