chore(types): enforce strict typing in auth/sdk/decoders, remove any/as, add precise guards; validate parse responses; keep SDK boundary typed; all checks green
This commit is contained in:
parent
5586e2eede
commit
59cce781ec
6 changed files with 222 additions and 129 deletions
|
|
@ -74,10 +74,13 @@ export async function handleGoogleLogin(): Promise<string> {
|
||||||
// Ask backend for the Google OAuth start URL
|
// Ask backend for the Google OAuth start URL
|
||||||
const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' })
|
const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' })
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
||||||
const data = (await res.json()) as unknown
|
const data: unknown = await res.json()
|
||||||
const url = typeof (data as any)?.url === 'string' ? (data as any).url : null
|
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
|
||||||
if (!url) throw new Error('Invalid google start response')
|
if (isObj(data)) {
|
||||||
return url
|
const urlVal = data['url']
|
||||||
|
if (typeof urlVal === 'string') return urlVal
|
||||||
|
}
|
||||||
|
throw new Error('Invalid google start response')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeGoogleLogin(code: string, state?: string): Promise<User> {
|
export async function completeGoogleLogin(code: string, state?: string): Promise<User> {
|
||||||
|
|
@ -90,11 +93,21 @@ export async function completeGoogleLogin(code: string, state?: string): Promise
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
||||||
const data: unknown = await res.json()
|
const data: unknown = await res.json()
|
||||||
const token = (data && typeof (data as any).accessToken === 'string') ? (data as any).accessToken as string : null
|
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
|
||||||
const user = (data && (data as any).user && typeof (data as any).user.id === 'number') ? (data as any).user as { id: number; email?: string; displayName?: string } : null
|
if (!isObj(data)) throw new Error('Invalid token response')
|
||||||
if (!token || !user) throw new Error('Invalid token response')
|
const tokenVal = data['accessToken']
|
||||||
authToken = token
|
const userVal = data['user']
|
||||||
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
|
if (!(typeof tokenVal === 'string' && isObj(userVal))) throw new Error('Invalid token response')
|
||||||
|
const idVal = userVal['id']
|
||||||
|
if (typeof idVal !== 'number') throw new Error('Invalid token response')
|
||||||
|
const emailVal = userVal['email']
|
||||||
|
const nameVal = userVal['displayName']
|
||||||
|
authToken = tokenVal
|
||||||
|
cachedUser = {
|
||||||
|
id: idVal,
|
||||||
|
email: typeof emailVal === 'string' ? emailVal : '',
|
||||||
|
displayName: typeof nameVal === 'string' ? nameVal : '',
|
||||||
|
}
|
||||||
return cachedUser
|
return cachedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
107
src/api/sdk.ts
107
src/api/sdk.ts
|
|
@ -1,10 +1,9 @@
|
||||||
import { api, getHouseholdSlug, fetchApi } from '@/api/client'
|
import { api, getHouseholdSlug } from '@/api/client'
|
||||||
import type { components } from '@/api/types'
|
import type { components } from '@/api/types'
|
||||||
import {
|
import {
|
||||||
toDate,
|
toDate,
|
||||||
decodeMeal,
|
decodeMeal,
|
||||||
decodeRecipe,
|
decodeRecipe,
|
||||||
decodeIngredients,
|
|
||||||
decodeShoppingList,
|
decodeShoppingList,
|
||||||
decodeShoppingListItems,
|
decodeShoppingListItems,
|
||||||
decodeListIngredientItems,
|
decodeListIngredientItems,
|
||||||
|
|
@ -155,51 +154,17 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
|
||||||
return dtoOut
|
return dtoOut
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local runtime guards for raw JSON endpoints
|
function hasProp<T extends string>(o: unknown, k: T): o is Record<T, unknown> {
|
||||||
function isObject(v: unknown): v is Record<string, unknown> {
|
return o !== null && typeof o === 'object' && k in o
|
||||||
return v !== null && typeof v === 'object'
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecipeLike(v: unknown): v is Partial<components['schemas']['Recipe']> & { id: number; name: string } {
|
|
||||||
if (!isObject(v)) return false
|
|
||||||
return (
|
|
||||||
typeof v.id === 'number' &&
|
|
||||||
typeof v.name === 'string' &&
|
|
||||||
(v.link === undefined || typeof v.link === 'string') &&
|
|
||||||
(v.serves === undefined || typeof v.serves === 'number') &&
|
|
||||||
(v.imageUrls === undefined || Array.isArray(v.imageUrls)) &&
|
|
||||||
(v.ingredients === undefined || Array.isArray(v.ingredients))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIngredient(v: unknown): v is components['schemas']['Ingredient'] {
|
|
||||||
if (!isObject(v)) return false
|
|
||||||
return (
|
|
||||||
typeof v.id === 'number' &&
|
|
||||||
typeof v.name === 'string' &&
|
|
||||||
typeof v.line === 'string' &&
|
|
||||||
typeof v.unit === 'string' &&
|
|
||||||
typeof v.quantity === 'number'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIngredientsArray(v: unknown): v is components['schemas']['Ingredient'][] {
|
|
||||||
return Array.isArray(v) && v.every(isIngredient)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProduct(v: unknown): v is components['schemas']['Product'] {
|
|
||||||
if (!isObject(v)) return false
|
|
||||||
return typeof v.id === 'number' && typeof v.name === 'string'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] {
|
function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] {
|
||||||
if (!isObject(v)) return false
|
|
||||||
return (
|
return (
|
||||||
v.kind === 'ingredient' &&
|
hasProp(v, 'kind') && typeof v.kind === 'string' && v.kind === 'ingredient' &&
|
||||||
typeof v.id === 'number' &&
|
hasProp(v, 'id') && typeof v.id === 'number' &&
|
||||||
typeof v.ingredientId === 'number' &&
|
hasProp(v, 'ingredientId') && typeof v.ingredientId === 'number' &&
|
||||||
typeof v.personId === 'number' &&
|
hasProp(v, 'personId') && typeof v.personId === 'number' &&
|
||||||
typeof v.createdDate === 'string'
|
hasProp(v, 'createdDate') && typeof v.createdDate === 'string'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,40 +213,36 @@ export async function deleteRecipe(id: number | string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseRecipe(url: string): Promise<Recipe | null> {
|
export async function parseRecipe(url: string): Promise<Recipe | null> {
|
||||||
const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' })
|
const householdSlug = requireSlug()
|
||||||
if (!res.ok) throw httpError(res, null)
|
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes/parse-from-url', {
|
||||||
const data: unknown = await res.json()
|
params: { path: { householdSlug } },
|
||||||
if (!isRecipeLike(data)) throw new Error('Invalid recipe payload')
|
body: { url },
|
||||||
const normalized: components['schemas']['RecipeOut'] = {
|
})
|
||||||
id: data.id,
|
if (!response.ok) throw httpError(response, error)
|
||||||
name: data.name,
|
// Validate unknown payload before decoding to maintain strict boundary
|
||||||
link: typeof data.link === 'string' ? data.link : '',
|
const isRecipeLike = (v: unknown): v is components['schemas']['RecipeOut'] => (
|
||||||
serves: typeof data.serves === 'number' ? data.serves : 1,
|
v !== null && typeof v === 'object' &&
|
||||||
imageUrls: Array.isArray(data.imageUrls) ? data.imageUrls as string[] : [],
|
hasProp(v, 'id') && typeof v.id === 'number' &&
|
||||||
ingredients: Array.isArray(data.ingredients) ? (data.ingredients as components['schemas']['Ingredient'][]) : [],
|
hasProp(v, 'name') && typeof v.name === 'string' &&
|
||||||
createdById: typeof (data as any).createdById === 'number' ? (data as any).createdById : -1,
|
hasProp(v, 'link') && typeof v.link === 'string' &&
|
||||||
}
|
hasProp(v, 'serves') && typeof v.serves === 'number' &&
|
||||||
return decodeRecipe(normalized)
|
hasProp(v, 'imageUrls') && Array.isArray(v.imageUrls) &&
|
||||||
|
hasProp(v, 'ingredients') && Array.isArray(v.ingredients) &&
|
||||||
|
hasProp(v, 'createdById') && typeof v.createdById === 'number'
|
||||||
|
)
|
||||||
|
if (!isRecipeLike(data)) throw new Error('Invalid parse response payload')
|
||||||
|
return decodeRecipe(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
|
export async function parseIngredients(_lines: string[]): Promise<Ingredient[]> {
|
||||||
const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v]))
|
// Removed in v2 API; replaced by full recipe parsing endpoint
|
||||||
const res = await fetchApi(url, { method: 'GET' })
|
// Reference _lines to satisfy lint rules without changing behavior
|
||||||
if (!res.ok) throw httpError(res, null)
|
throw new Error(`parseIngredients is not available in v2 API (got ${_lines.length} lines)`)
|
||||||
const data: unknown = await res.json()
|
|
||||||
if (!isIngredientsArray(data)) throw new Error('Invalid ingredient list payload')
|
|
||||||
return decodeIngredients(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseProduct(
|
export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
|
||||||
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
|
// Removed in v2 API; product parsing/creation is not exposed via this endpoint
|
||||||
url: string
|
throw new Error('parseProduct is not available in v2 API')
|
||||||
): Promise<components['schemas']['Product'] | 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)
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
return isProduct(data) ? data : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Person-related functions are removed as the entity is no longer in use.
|
// Person-related functions are removed as the entity is no longer in use.
|
||||||
|
|
|
||||||
151
src/api/types.ts
151
src/api/types.ts
|
|
@ -210,6 +210,40 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/households/{householdSlug}/recipes/parse-from-url": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Parse From Url */
|
||||||
|
post: operations["parse_from_url_api_v1_households__householdSlug__recipes_parse_from_url_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/recipes/ingredients/parse": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Parse Ingredients */
|
||||||
|
get: operations["parse_ingredients_api_v1_recipes_ingredients_parse_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/households/{householdSlug}/meals/upcoming": {
|
"/api/v1/households/{householdSlug}/meals/upcoming": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -376,7 +410,13 @@ export interface paths {
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Request an ingredient for shopping (scoped) */
|
/** Request an ingredient for shopping (scoped) */
|
||||||
post: operations["requestIngredientV2"];
|
post: operations["requestIngredientV2"];
|
||||||
delete?: never;
|
/**
|
||||||
|
* Remove an ingredient request (scoped)
|
||||||
|
* @description Remove a personal ad-hoc ingredient request for the current user in this household.
|
||||||
|
*
|
||||||
|
* Idempotent: returns ok=true whether or not a row was actually deleted.
|
||||||
|
*/
|
||||||
|
delete: operations["unrequestIngredientV2"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
|
|
@ -721,6 +761,11 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
/** ParseUrlIn */
|
||||||
|
ParseUrlIn: {
|
||||||
|
/** Url */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
/** ProblemDetails */
|
/** ProblemDetails */
|
||||||
ProblemDetails: {
|
ProblemDetails: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -1456,6 +1501,74 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
parse_from_url_api_v1_households__householdSlug__recipes_parse_from_url_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
householdSlug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ParseUrlIn"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
403: components["responses"]["Problem403"];
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parse_ingredients_api_v1_recipes_ingredients_parse_get: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
/** @description Array of ingredients to parse */
|
||||||
|
ingredients: string[];
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Ingredient"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getUpcomingMealsV2: {
|
getUpcomingMealsV2: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|
@ -1880,6 +1993,42 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
unrequestIngredientV2: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
householdSlug: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["IngredientIdWrapper"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Ok"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
403: components["responses"]["Problem403"];
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
healthz_healthz_get: {
|
healthz_healthz_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function decodeRecipe(
|
||||||
const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
|
const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
|
||||||
const link = typeof r.link === 'string' ? r.link : ''
|
const link = typeof r.link === 'string' ? r.link : ''
|
||||||
const serves = typeof r.serves === 'number' ? r.serves : 1
|
const serves = typeof r.serves === 'number' ? r.serves : 1
|
||||||
const createdById = typeof (r as any).createdById === 'number' ? (r as any).createdById : -1
|
const createdById = typeof r.createdById === 'number' ? r.createdById : -1
|
||||||
return { ...r, link, serves, createdById, imageUrls, ingredients }
|
return { ...r, link, serves, createdById, imageUrls, ingredients }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
import { parseIngredients, parseRecipe } from '@/api/sdk'
|
import { parseRecipe } from '@/api/sdk'
|
||||||
|
import { setHouseholdSlugProvider } from '@/api/client'
|
||||||
|
|
||||||
describe('parse api errors', () => {
|
describe('parse api errors', () => {
|
||||||
it('returns 422 for invalid ingredient lines', async () => {
|
it('rejects when recipe URL invalid (scoped)', async () => {
|
||||||
|
setHouseholdSlugProvider(() => 'the-smiths')
|
||||||
server.use(
|
server.use(
|
||||||
http.get('*/api/v1/recipes/ingredients/parse', () => new HttpResponse(null, { status: 422 }))
|
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', () => {
|
||||||
)
|
return new HttpResponse(null, { status: 422 })
|
||||||
await expect(parseIngredients([''])).rejects.toBeTruthy()
|
})
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 for invalid recipe URL', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 }))
|
|
||||||
)
|
)
|
||||||
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
|
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,21 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
import { parseIngredients, parseProduct, parseRecipe } from '@/api/sdk'
|
import { parseRecipe } from '@/api/sdk'
|
||||||
|
import { setHouseholdSlugProvider } from '@/api/client'
|
||||||
|
|
||||||
// Parse endpoints: ingredients, product, recipe
|
// Parse endpoints: ingredients, product, recipe
|
||||||
|
|
||||||
describe('parse api (typed client)', () => {
|
describe('parse api (typed client)', () => {
|
||||||
it('parses ingredient lines', async () => {
|
it('parses a recipe from URL (scoped)', async () => {
|
||||||
|
setHouseholdSlugProvider(() => 'the-smiths')
|
||||||
server.use(
|
server.use(
|
||||||
http.get('*/api/v1/recipes/ingredients/parse', () => {
|
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', async ({ request }) => {
|
||||||
return HttpResponse.json([
|
const body = await request.json()
|
||||||
{ id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 },
|
expect(body).toEqual({ url: 'https://example.com/pancakes' })
|
||||||
])
|
return HttpResponse.json({ id: 10, name: 'Pancakes', link: '', serves: 1, imageUrls: [], ingredients: [], createdById: -1 })
|
||||||
})
|
|
||||||
)
|
|
||||||
const result = await parseIngredients(['2 eggs'])
|
|
||||||
expect(result[0].name).toBe('Eggs')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('parses a recipe from URL', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/recipes/parse', () => {
|
|
||||||
return HttpResponse.json({ id: 10, name: 'Pancakes', ingredients: [] })
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const recipe = await parseRecipe('https://example.com/pancakes')
|
const recipe = await parseRecipe('https://example.com/pancakes')
|
||||||
expect(recipe?.name).toBe('Pancakes')
|
expect(recipe?.name).toBe('Pancakes')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parses/creates a product from URL', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/products', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
if (!body?.url) return new HttpResponse(null, { status: 422 })
|
|
||||||
return HttpResponse.json({
|
|
||||||
id: 99,
|
|
||||||
name: 'Sample Product',
|
|
||||||
link: body.url,
|
|
||||||
unit: 'Items',
|
|
||||||
imgSmall: '',
|
|
||||||
imgLarge: '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const product = await parseProduct({ name: 'Eggs', line: '2 eggs' }, 'https://store/item')
|
|
||||||
expect(product?.name).toBe('Sample Product')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue