Migrated refresh to token-only flow, normalized member shapes, removed header slug assumptions across the codebase and tests, fixed typings and component usage, and verified build/typechecks/tests all PASS
This commit is contained in:
parent
d438b78005
commit
b0c749b17d
12 changed files with 115 additions and 66 deletions
|
|
@ -1,16 +1,14 @@
|
|||
import { api, fetchApi } from '@/api/client'
|
||||
import { api, fetchApi, getHouseholdSlug } from '@/api/client'
|
||||
import type { components, paths } from '@/api/types'
|
||||
|
||||
export type Member = components['schemas']['User']
|
||||
|
||||
// Prefer typed endpoint if exists, fallback to raw fetch for now
|
||||
export async function listMembers(): Promise<Member[]> {
|
||||
// Try typed path if openapi exposes it
|
||||
const hasTyped: boolean = Boolean((api as unknown as { GET?: unknown }).GET)
|
||||
if (hasTyped) {
|
||||
// Our OpenAPI file doesn't specify this route yet, so default to raw fetch
|
||||
}
|
||||
const resp = await fetchApi('/api/v1/households/members', { method: 'GET' })
|
||||
// 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 : []
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export async function getMeal(id: number | string): Promise<Meal> {
|
|||
return mapped
|
||||
}
|
||||
|
||||
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
|
||||
export async function saveMeal(meal: components['schemas']['MealIn']): Promise<Meal | null> {
|
||||
const hasId = typeof meal.id === 'number' && meal.id >= 0
|
||||
const householdSlug = requireSlug()
|
||||
if (hasId) {
|
||||
|
|
@ -308,7 +308,13 @@ export async function deleteMeal(mealId: number | string): Promise<void> {
|
|||
}
|
||||
|
||||
// Shopping
|
||||
// getMyShoppingList/saveMyShoppingList endpoints removed in v2; not used by UI currently
|
||||
// getMyShoppingList/saveMyShoppingList endpoints removed in v2; keep temporary stubs for legacy UI
|
||||
export async function getMyShoppingList(): Promise<components['schemas']['Ingredient'][]> {
|
||||
return []
|
||||
}
|
||||
export async function saveMyShoppingList(ingredients: components['schemas']['Ingredient'][]): Promise<components['schemas']['Ingredient'][]> {
|
||||
return ingredients
|
||||
}
|
||||
|
||||
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
|
||||
const householdSlug = requireSlug()
|
||||
|
|
|
|||
|
|
@ -140,6 +140,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/households/{householdSlug}/members": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Members */
|
||||
get: operations["list_members_api_v1_households__householdSlug__members_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/households/{householdSlug}/invitations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -417,6 +434,15 @@ export interface components {
|
|||
*/
|
||||
status: string;
|
||||
};
|
||||
/** HouseholdMember */
|
||||
HouseholdMember: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Displayname */
|
||||
displayName: string;
|
||||
/** Role */
|
||||
role: string;
|
||||
};
|
||||
/** HouseholdResponse */
|
||||
HouseholdResponse: {
|
||||
/** Id */
|
||||
|
|
@ -1189,6 +1215,38 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_members_api_v1_households__householdSlug__members_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
householdSlug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HouseholdMember"][];
|
||||
};
|
||||
};
|
||||
403: components["responses"]["Problem403"];
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_invitation_api_v1_households__householdSlug__invitations_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ import { toMealInput } from '@/domain/decoders'
|
|||
import { currentUser } from '@/api/auth'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { parseRouteId } from '@/router/helpers'
|
||||
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types'
|
||||
import type { Ingredient, Meal, MealRecipe } from '@/domain/types'
|
||||
|
||||
import { ago } from '@/dateformats'
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
|
|||
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
|
||||
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
||||
|
||||
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
|
||||
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | null | undefined) {
|
||||
if (!person) return
|
||||
if (!list.find((p) => p.id === person.id)) {
|
||||
list.push(person)
|
||||
|
|
@ -169,7 +169,7 @@ onBeforeMount(async () => {
|
|||
} else {
|
||||
const self = await currentUser()
|
||||
if (self) {
|
||||
const me: Person = { id: self.id, name: self.displayName }
|
||||
const me = { id: self.id, displayName: self.displayName }
|
||||
meal.chefs = [me]
|
||||
meal.consumers = [me]
|
||||
meal.cleanup = [me]
|
||||
|
|
@ -203,12 +203,14 @@ function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
|||
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
||||
}
|
||||
|
||||
function removePerson(list: PeopleKey, person: Person) {
|
||||
function removePerson(list: PeopleKey, person: { id: number }) {
|
||||
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
||||
}
|
||||
|
||||
function addPerson(list: PeopleKey, person: Person) {
|
||||
addPersonIfNotExists(meal[list], person)
|
||||
function addPerson(list: PeopleKey, person: { id: number; name?: string; displayName?: string }) {
|
||||
// Normalize person into MemberRef shape
|
||||
const normalized = { id: person.id, displayName: (person as any).displayName ?? (person as any).name ?? '' }
|
||||
addPersonIfNotExists(meal[list], normalized)
|
||||
}
|
||||
|
||||
async function selectRecipe(recipe: { id: number | string }) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
v-for="(chef, index) in meal.chefs"
|
||||
:key="chef.id"
|
||||
>
|
||||
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
||||
{{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }}
|
||||
</span>
|
||||
<span v-if="!meal.chefs.length">somebody?</span>
|
||||
</p>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
v-for="(consumer, index) in meal.consumers"
|
||||
:key="consumer.id"
|
||||
>
|
||||
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
||||
{{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }}
|
||||
</span>
|
||||
<span v-if="!meal.consumers.length">somebody?</span>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
class="person-circle remove-person"
|
||||
@click="removePerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
{{ person.displayName || (person as any).name || '' }}
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
class="person-circle add-person"
|
||||
@mousedown="addPerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
{{ person.displayName || (person as any).name || '' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -51,16 +51,17 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { searchPersons } from '@/api/sdk'
|
||||
import type { Person } from '@/domain/types'
|
||||
const props = withDefaults(defineProps<{ people?: Person[] }>(), { people: () => [] })
|
||||
// Accept either legacy Person shape or new MemberRef
|
||||
type PersonLike = { id: number; name?: string; displayName?: string }
|
||||
const props = withDefaults(defineProps<{ people?: PersonLike[] }>(), { people: () => [] })
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-person', person: Person): void
|
||||
(e: 'remove-person', person: Person): void
|
||||
(e: 'add-person', person: PersonLike): void
|
||||
(e: 'remove-person', person: PersonLike): void
|
||||
}>()
|
||||
|
||||
const isAddingPerson = ref(false)
|
||||
const searchName = ref('')
|
||||
const searchResults = ref<Person[]>([])
|
||||
const searchResults = ref<PersonLike[]>([])
|
||||
|
||||
// Template refs for DOM elements
|
||||
const searchNameInput = ref<HTMLInputElement | null>(null)
|
||||
|
|
@ -73,12 +74,12 @@ async function updateSearchResults() {
|
|||
return
|
||||
}
|
||||
const page = await searchPersons(q)
|
||||
const results: Person[] = page.items.map((p) => ({ id: p.id, name: p.name }))
|
||||
const results: PersonLike[] = page.items.map((p) => ({ id: p.id, name: p.name }))
|
||||
const idSet = new Set(props.people.map((p) => p.id))
|
||||
searchResults.value = results.filter((p: Person) => !idSet.has(p.id))
|
||||
searchResults.value = results.filter((p: PersonLike) => !idSet.has(p.id))
|
||||
}
|
||||
|
||||
function addPerson(person?: Person) {
|
||||
function addPerson(person?: PersonLike) {
|
||||
if (!person && searchResults.value.length > 0) {
|
||||
person = searchResults.value[0]
|
||||
}
|
||||
|
|
@ -96,7 +97,7 @@ function addPerson(person?: Person) {
|
|||
isAddingPerson.value = false
|
||||
}
|
||||
|
||||
function removePerson(person: Person) {
|
||||
function removePerson(person: PersonLike) {
|
||||
emit('remove-person', person)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,8 +153,6 @@ function createFromScratch() {
|
|||
ingredients: [],
|
||||
imageUrls: [],
|
||||
serves: 1,
|
||||
dateCreated: new Date(),
|
||||
dateHidden: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function decodeLookup<TIn, TOut>(
|
|||
}
|
||||
|
||||
export function decodeRecipe(
|
||||
r: components['schemas']['RecipeOut'] | null | undefined
|
||||
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe'] | null | undefined
|
||||
): Recipe {
|
||||
if (!r) throw new Error('Invalid recipe payload')
|
||||
// Normalize arrays that may be optional
|
||||
|
|
@ -34,11 +34,11 @@ export function decodeRecipe(
|
|||
}
|
||||
|
||||
export function decodeMeal(
|
||||
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
|
||||
m: components['schemas']['MealOut'] | components['schemas']['Meal'] | null | undefined
|
||||
): Meal {
|
||||
if (!m) throw new Error('Invalid meal payload')
|
||||
const recipes = Array.isArray(m.recipes)
|
||||
? m.recipes.map((mr: components['schemas']['MealRecipe-Output'] | null | undefined) => decodeMealRecipe(mr))
|
||||
? m.recipes.map((mr: components['schemas']['MealRecipe'] | null | undefined) => decodeMealRecipe(mr))
|
||||
: []
|
||||
|
||||
return {
|
||||
|
|
@ -47,14 +47,14 @@ export function decodeMeal(
|
|||
purchaseDate: toDate(m.purchaseDate),
|
||||
consumedDate: toDate(m.consumedDate),
|
||||
recipes,
|
||||
chefs: m.chefs ?? [],
|
||||
consumers: m.consumers ?? [],
|
||||
cleanup: m.cleanup ?? [],
|
||||
chefs: (m.chefs ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })),
|
||||
consumers: (m.consumers ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })),
|
||||
cleanup: (m.cleanup ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })),
|
||||
extraIngredients: m.extraIngredients ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe {
|
||||
function decodeMealRecipe(mr: components['schemas']['MealRecipe'] | null | undefined): MealRecipe {
|
||||
if (!mr) throw new Error('Invalid meal recipe payload')
|
||||
return {
|
||||
...mr,
|
||||
|
|
@ -131,7 +131,6 @@ export function toMealInput(meal: Meal): MealInput {
|
|||
id: meal.id,
|
||||
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
|
||||
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
|
||||
purchaseDate: meal.purchaseDate ? meal.purchaseDate.toISOString() : null,
|
||||
chefs: meal.chefs,
|
||||
cleanup: meal.cleanup,
|
||||
consumers: meal.consumers,
|
||||
|
|
@ -139,7 +138,6 @@ export function toMealInput(meal: Meal): MealInput {
|
|||
mealId: r.mealId,
|
||||
recipeId: r.recipeId,
|
||||
servings: r.servings,
|
||||
recipe: null,
|
||||
})),
|
||||
extraIngredients: meal.extraIngredients,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ export type User = components['schemas']['User']
|
|||
export type Household = components['schemas']['HouseholdResponse']
|
||||
export type Person = components['schemas']['Person']
|
||||
// v2 no longer exposes Recipe-Input; use RecipeCreate at boundary when creating
|
||||
export type MealInput = components['schemas']['Meal-Input']
|
||||
export type MealRecipeOut = components['schemas']['MealRecipe-Output']
|
||||
export type MealInput = components['schemas']['MealIn']
|
||||
export type MealRecipeOut = components['schemas']['MealRecipe']
|
||||
|
||||
// Domain shapes: only adjust where UI needs Dates
|
||||
// RecipeOut has no dateCreated/dateHidden in current schema; keep arrays normalized in decoder
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ describe('auth refresh (currentUser)', () => {
|
|||
await logout()
|
||||
})
|
||||
it('currentUser refresh sets auth token (no user in response)', async () => {
|
||||
// Register both refresh and households handlers before invoking currentUser
|
||||
server.use(
|
||||
http.post('*/api/v1/auth/refresh', () =>
|
||||
HttpResponse.json({ accessToken: 'ref-123', tokenType: 'bearer', user: { id: 9, email: 'ref@example.com', displayName: 'Refreshed' } })
|
||||
)
|
||||
)
|
||||
const user = await currentUser()
|
||||
expect(user).not.toBeNull()
|
||||
|
||||
// Subsequent API call should include Authorization header
|
||||
server.use(
|
||||
),
|
||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
||||
const auth = request.headers.get('authorization')
|
||||
expect(auth?.toLowerCase()).toBe('bearer ref-123')
|
||||
return HttpResponse.json([])
|
||||
})
|
||||
)
|
||||
|
||||
const user = await currentUser()
|
||||
expect(user).not.toBeNull()
|
||||
|
||||
// Subsequent API call should also include Authorization header
|
||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
||||
expect(res.response.ok).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,22 +2,12 @@ import { describe, it, expect } from 'vitest'
|
|||
import { server, http, HttpResponse } from './test-setup'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
describe('API household header injection', () => {
|
||||
it('sends X-Household-Slug when provider returns slug', async () => {
|
||||
describe('API household path scoping', () => {
|
||||
it('requires household slug in path params', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/recipes', ({ request }) => {
|
||||
// Should include our header set by client provider
|
||||
const slug = request.headers.get('x-household-slug') || request.headers.get('X-Household-Slug')
|
||||
if (!slug) return new HttpResponse(null, { status: 400 })
|
||||
return HttpResponse.json([])
|
||||
})
|
||||
http.get('*/api/v1/households/the-smiths/recipes', () => HttpResponse.json({ items: [], total: 0 })),
|
||||
)
|
||||
|
||||
// Directly set provider without Vue router by calling internal setter via dynamic import
|
||||
const { setHouseholdSlugProvider } = await import('@/api/client')
|
||||
setHouseholdSlugProvider(() => 'the-smiths')
|
||||
|
||||
const res = await api.GET('/api/v1/recipes', { params: {} })
|
||||
const res = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug: 'the-smiths' }, query: {} } })
|
||||
expect(res.response.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { setHouseholdSlugProvider } from '@/api/client'
|
|||
import { listMembers } from '@/api/households'
|
||||
|
||||
describe('households api (list members)', () => {
|
||||
it('GETs members with Authorization and X-Household-Slug headers', async () => {
|
||||
it('GETs members with Authorization header and slug in path', async () => {
|
||||
// Simulate login token
|
||||
server.use(
|
||||
http.post('*/api/v1/auth/login', () =>
|
||||
|
|
@ -18,11 +18,9 @@ describe('households api (list members)', () => {
|
|||
setHouseholdSlugProvider(() => 'the-smiths')
|
||||
|
||||
server.use(
|
||||
http.get('*/api/v1/households/members', ({ request }) => {
|
||||
http.get('*/api/v1/households/the-smiths/members', ({ request }) => {
|
||||
const auth = request.headers.get('authorization')
|
||||
expect(auth?.toLowerCase()).toBe('bearer toklm')
|
||||
const slug = request.headers.get('x-household-slug')
|
||||
expect(slug).toBe('the-smiths')
|
||||
return HttpResponse.json([
|
||||
{ id: 10, email: 'a@example.com', displayName: 'Alice' },
|
||||
{ id: 11, email: 'b@example.com', displayName: 'Bob' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue