housekeeping
This commit is contained in:
parent
faf0a91d2d
commit
a446bc1c11
15 changed files with 402 additions and 66 deletions
|
|
@ -57,37 +57,40 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
|
|||
|
||||
## 4. Actionable Implementation Steps
|
||||
|
||||
1. **[ ] Refactor Authentication State & API**:
|
||||
1. **[~] Refactor Authentication State & API**:
|
||||
- **Modify `src/api/auth.ts`**:
|
||||
- Replace `login(username: string)` with `login(email, password)`.
|
||||
- Add `createAccount(email, displayName, password)`, `handleGoogleLogin(token)`, and `logout()`.
|
||||
- The API client should handle storing and clearing the auth token (e.g., from `localStorage`).
|
||||
- Added minimal multitenant-ready surface while preserving legacy login:
|
||||
- `loginWithPassword(email, password)` implemented by delegating to legacy `login(username)`; tests added.
|
||||
- `logout()` clears cached user; covered by tests.
|
||||
- Stubs for `createAccount(email, displayName, password)` and `handleGoogleLogin(token)` throw pending-implementation errors; tests assert presence.
|
||||
- Token/JWT storage is still cookie-based (per current backend); no localStorage changes yet.
|
||||
- **Modify `src/composables/useAuth.ts`**:
|
||||
- Update the `login` function to accept email/password.
|
||||
- Add a `register` function.
|
||||
- The `user` ref should now hold the global User profile, and you should add a new state for `households` and `activeHousehold`.
|
||||
- The `logout` function should clear the JWT and all user state.
|
||||
- TODO: Update to accept email/password and expose `register`, `logout`, `households`, and `activeHousehold`. Not started.
|
||||
|
||||
2. **[ ] Update Router for Multi-Tenancy**:
|
||||
2. **[~] Update Router for Multi-Tenancy**:
|
||||
- **Modify `src/router/index.ts`**:
|
||||
- Add new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
|
||||
- Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
|
||||
- Feature flag `VUE_APP_MULTITENANT_ENABLED` controls nesting:
|
||||
- When enabled: feature routes are nested under `/:householdSlug/...`.
|
||||
- When disabled: legacy flat routes remain for backward compatibility.
|
||||
- **Refactor the `beforeEach` guard**:
|
||||
- It should allow access to public routes.
|
||||
- After login, it must fetch the user's households.
|
||||
- If the user has no households, redirect to `/welcome`.
|
||||
- If the user has households but is at the root (`/`), redirect to the first household's dashboard (e.g., `/${householdSlug}/dashboard`).
|
||||
- **Nest existing routes**: All current data-related routes (`/meals`, `/shopping`, etc.) must be moved as children of a new dynamic `/:householdSlug` route.
|
||||
- **Nest existing routes**: Implemented behind feature flag.
|
||||
|
||||
3. **[ ] Implement Onboarding and Invitation Flows**:
|
||||
- Build the `Welcome.vue` view for creating the first household.
|
||||
- Build the `CreateAccount.vue` view.
|
||||
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
|
||||
- Status: Placeholder views created for all three; logic pending.
|
||||
|
||||
4. **[ ] Integrate Household Context into the App**:
|
||||
- **Create `src/composables/useHousehold.ts`**: This composable should extract the `householdSlug` from the current route's params. It will provide the `activeHouseholdSlug` to any component or service that needs it.
|
||||
- **Implement `HouseholdSwitcher.vue`**: This component will use `useAuth` to get the list of the user's households and render navigation links.
|
||||
- **Update API Services**: All data-fetching calls (e.g., for recipes, meals) must be updated to use the `activeHouseholdSlug` from `useHousehold`. The API client wrapper should be modified to prepend this slug to the request URL.
|
||||
- Example: `api.get('/recipes')` becomes `api.get(\`/${activeHouseholdSlug.value}/recipes\`)`.
|
||||
4. **[~] Integrate Household Context into the App**:
|
||||
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
||||
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
|
||||
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` using a configurable provider; no path changes.
|
||||
- Verified by `tests/household.header.test.ts`.
|
||||
|
||||
5. **[ ] Implement Invitation UI**:
|
||||
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
|
||||
|
|
@ -119,11 +122,11 @@ What exists today
|
|||
|
||||
Gaps vs requirements
|
||||
- Authentication & onboarding
|
||||
- Missing email/password login, Google OAuth, account creation, logout, JWT storage/refresh.
|
||||
- Partial: Added `loginWithPassword` shim and `logout`. Still missing Google OAuth, account creation, JWT storage/refresh beyond cookie.
|
||||
- Missing `CreateAccount.vue`, `Welcome.vue`, invitation acceptance flow.
|
||||
- Routing & URL-based tenancy
|
||||
- Missing `/:householdSlug/...` route nesting and post-login redirect logic.
|
||||
- No `useHousehold.ts`; no `HouseholdSwitcher.vue`.
|
||||
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
|
||||
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.
|
||||
- API boundary
|
||||
- No household scoping passed to backend. Need a typed strategy (prefer header parameter) without breaking OpenAPI typing.
|
||||
- Cleanup
|
||||
|
|
@ -144,6 +147,18 @@ To align with OpenAPI typing and README axioms, do not rewrite request paths to
|
|||
|
||||
Action
|
||||
- Implement header injection in `api/client.ts` with a pluggable getter for the active slug (decoupled from Vue imports). Update this spec once backend finalizes the parameter shape.
|
||||
- DONE: Implemented with `setHouseholdSlugProvider`. Will align to OpenAPI when backend finalizes.
|
||||
|
||||
---
|
||||
|
||||
Progress Log (Nov 1, 2025)
|
||||
- Established green baseline (typecheck + tests pass).
|
||||
- Added auth API tests driving a minimal multitenant-ready surface.
|
||||
- Implemented `loginWithPassword`, `logout`, and stubs in `src/api/auth.ts` to satisfy tests.
|
||||
- Refactored `useAuth` to add households and activeHousehold state, plus `loginWithPassword` and `logout`. Tests added and passing.
|
||||
- Added router tests and implemented feature-flagged nested routes and new public routes. Placeholders for onboarding/invitations added.
|
||||
- Implemented `useHousehold.ts`, header injection provider in API client, minimal `HouseholdSwitcher.vue`, and mounted it. Added a header injection test.
|
||||
- Next: Update `LoginPage.vue` to email/password UI using `useAuth.loginWithPassword`; implement router guard post-login household redirects; scaffold `HouseholdSettings.vue` and households fetching in `useAuth`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<HouseholdSwitcher />
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<router-view />
|
||||
|
|
@ -39,6 +40,11 @@
|
|||
|
||||
<script setup>
|
||||
import AlertToast from './components/AlertToast.vue'
|
||||
import HouseholdSwitcher from '@/components/HouseholdSwitcher.vue'
|
||||
import { useHousehold } from '@/composables/useHousehold'
|
||||
|
||||
// Initialize household slug provider binding to current route
|
||||
useHousehold()
|
||||
|
||||
// components in <script setup> are auto-registered by import + usage
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -28,3 +28,24 @@ export async function login(username: string): Promise<Person> {
|
|||
if (!cachedUser) throw new Error('Login failed: empty response')
|
||||
return cachedUser
|
||||
}
|
||||
|
||||
// New multitenant-ready API surface (backward compatible)
|
||||
export async function loginWithPassword(email: string, _password: string): Promise<Person> {
|
||||
// Backend currently expects { username }; map email to username until OpenAPI updates
|
||||
return login(email)
|
||||
}
|
||||
|
||||
export async function createAccount(_email: string, _displayName: string, _password: string): Promise<never> {
|
||||
// Placeholder until backend endpoints and OpenAPI are finalized
|
||||
throw new Error('createAccount not implemented yet')
|
||||
}
|
||||
|
||||
export async function handleGoogleLogin(_token: string): Promise<never> {
|
||||
// Placeholder until Google OAuth flow is wired
|
||||
throw new Error('handleGoogleLogin not implemented yet')
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Clear local cache; server session is cookie-based and will be refreshed on next call
|
||||
cachedUser = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,22 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
|
|||
const defaultBase = ''
|
||||
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
|
||||
|
||||
let householdSlugProvider: (() => string | null) | null = null
|
||||
|
||||
export function setHouseholdSlugProvider(provider: (() => string | null) | null) {
|
||||
householdSlugProvider = provider
|
||||
}
|
||||
|
||||
export const api = createClient<paths>({
|
||||
baseUrl,
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers || {})
|
||||
const slug = householdSlugProvider ? householdSlugProvider() : null
|
||||
if (slug) headers.set('X-Household-Slug', slug)
|
||||
return globalThis.fetch(input, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
42
src/components/HouseholdSwitcher.vue
Normal file
42
src/components/HouseholdSwitcher.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div v-if="enabled && households.length > 0" class="household-switcher">
|
||||
<label>Household:</label>
|
||||
<ul>
|
||||
<li v-for="h in households" :key="h.slug">
|
||||
<router-link :to="toHousehold(h.slug)" :class="{ active: h.slug === activeSlug }">
|
||||
{{ h.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const enabled = (typeof process !== 'undefined' && (process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'))
|
||||
const route = useRoute()
|
||||
const { households } = useAuth()
|
||||
|
||||
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
|
||||
|
||||
function toHousehold(slug: string) {
|
||||
if (enabled) return { name: 'mealplan', params: { householdSlug: slug } }
|
||||
return { name: 'mealplan' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.household-switcher {
|
||||
display: inline-block;
|
||||
}
|
||||
.household-switcher ul {
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { ref } from 'vue'
|
||||
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
|
||||
import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout } from '@/api/auth'
|
||||
import type { Person } from '@/domain/types'
|
||||
|
||||
type Household = { id: number; name: string; slug: string }
|
||||
|
||||
const user = ref<Person | null>(null)
|
||||
const households = ref<Household[]>([])
|
||||
const activeHousehold = ref<Household | null>(null)
|
||||
let initialized = false
|
||||
|
||||
export async function loadUser() {
|
||||
|
|
@ -18,6 +22,34 @@ export async function login(username: string) {
|
|||
return user.value
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return { user, loadUser, login }
|
||||
export async function loginWithPassword(email: string, password: string) {
|
||||
user.value = await apiLoginWithPassword(email, password)
|
||||
return user.value
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await apiLogout()
|
||||
user.value = null
|
||||
households.value = []
|
||||
activeHousehold.value = null
|
||||
}
|
||||
|
||||
export function setHouseholds(hs: Household[]) {
|
||||
households.value = Array.isArray(hs) ? hs.slice() : []
|
||||
}
|
||||
|
||||
export function setActiveHousehold(h: Household | string | null) {
|
||||
if (h == null) {
|
||||
activeHousehold.value = null
|
||||
return
|
||||
}
|
||||
if (typeof h === 'string') {
|
||||
activeHousehold.value = households.value.find((x) => x.slug === h) ?? null
|
||||
} else {
|
||||
activeHousehold.value = h
|
||||
}
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return { user, households, activeHousehold, loadUser, login, loginWithPassword, logout, setHouseholds, setActiveHousehold }
|
||||
}
|
||||
|
|
|
|||
18
src/composables/useHousehold.ts
Normal file
18
src/composables/useHousehold.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { computed, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { setHouseholdSlugProvider } from '@/api/client'
|
||||
|
||||
export function useHousehold() {
|
||||
const route = useRoute()
|
||||
const activeHouseholdSlug = computed<string | null>(() => {
|
||||
const p = route.params?.householdSlug
|
||||
return typeof p === 'string' ? p : null
|
||||
})
|
||||
|
||||
// Keep API client in sync with current route's household slug
|
||||
watchEffect(() => {
|
||||
setHouseholdSlugProvider(() => activeHouseholdSlug.value)
|
||||
})
|
||||
|
||||
return { activeHouseholdSlug }
|
||||
}
|
||||
|
|
@ -10,55 +10,54 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
|
|||
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
||||
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
||||
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
||||
const CreateAccount = () => import('@/views/CreateAccount.vue')
|
||||
const Welcome = () => import('@/views/Welcome.vue')
|
||||
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
|
||||
|
||||
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: { name: 'mealplan' } },
|
||||
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
|
||||
const publicRoutes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/shopping',
|
||||
name: 'shopping',
|
||||
component: MyShoppingPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/shopping/current',
|
||||
name: 'shopping-current',
|
||||
component: CurrentShoppingListPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/shopping/:id',
|
||||
name: 'shopping-list',
|
||||
component: PurchasedShoppingListPage,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/recipes/add',
|
||||
name: 'recipe-add',
|
||||
component: EditRecipePage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/recipes/:id',
|
||||
name: 'recipe-edit',
|
||||
component: EditRecipePage,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/meals/:id',
|
||||
name: 'meal-edit',
|
||||
component: EditMealPage,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/create-account', name: 'create-account', component: CreateAccount },
|
||||
{ path: '/welcome', name: 'welcome', component: Welcome },
|
||||
{ path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
|
||||
]
|
||||
|
||||
const featureChildren: RouteRecordRaw[] = [
|
||||
{ path: 'recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
||||
{ path: 'recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
|
||||
{ path: 'recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
|
||||
{ path: 'mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
||||
{ path: 'meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||
{ path: 'meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
|
||||
{ path: 'shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
||||
{ path: 'shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
|
||||
{ path: 'shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
|
||||
]
|
||||
|
||||
const routes: RouteRecordRaw[] = []
|
||||
|
||||
if (multitenantEnabled) {
|
||||
routes.push({ path: '/', redirect: { name: 'login' } })
|
||||
routes.push(...publicRoutes)
|
||||
routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren })
|
||||
} else {
|
||||
routes.push({ path: '/', redirect: { name: 'mealplan' } })
|
||||
routes.push(...publicRoutes)
|
||||
// legacy flat routes
|
||||
routes.push(
|
||||
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
||||
{ path: '/recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
|
||||
{ path: '/recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
|
||||
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
||||
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||
{ path: '/meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
|
||||
{ path: '/shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
||||
{ path: '/shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
|
||||
{ path: '/shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
|
||||
)
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
|
|
|
|||
10
src/views/CreateAccount.vue
Normal file
10
src/views/CreateAccount.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>Create Account</h1>
|
||||
<p>Placeholder for email, display name, password, and Google signup.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Implement form per spec
|
||||
</script>
|
||||
10
src/views/InvitationAccept.vue
Normal file
10
src/views/InvitationAccept.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>Accept Invitation</h1>
|
||||
<p>Processing your invitation token...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Read token from route, call API, and redirect
|
||||
</script>
|
||||
10
src/views/Welcome.vue
Normal file
10
src/views/Welcome.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>Welcome</h1>
|
||||
<p>Choose to create a new household or join via invitation link.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Implement onboarding flow per spec
|
||||
</script>
|
||||
42
tests/auth.api.test.ts
Normal file
42
tests/auth.api.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { server, http, HttpResponse } from './test-setup'
|
||||
import { currentUser, logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth'
|
||||
|
||||
describe('auth api (multitenant prep)', () => {
|
||||
it('loginWithPassword proxies to /auth/login with username=email', async () => {
|
||||
server.use(
|
||||
http.post('*/api/v1/auth/login', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
expect(body).toEqual({ username: 'test@example.com' })
|
||||
return HttpResponse.json({ id: 123, name: 'Test User' })
|
||||
})
|
||||
)
|
||||
const person = await loginWithPassword('test@example.com', 'secret')
|
||||
expect(person.id).toBe(123)
|
||||
expect(person.name).toBe('Test User')
|
||||
})
|
||||
|
||||
it('logout clears cached user; subsequent currentUser returns null', async () => {
|
||||
// First refresh returns a user
|
||||
server.use(
|
||||
http.post('*/api/v1/auth/refresh', () => {
|
||||
return HttpResponse.json({ id: 1, name: 'Ada' })
|
||||
})
|
||||
)
|
||||
const first = await currentUser()
|
||||
expect(first?.name).toBe('Ada')
|
||||
|
||||
// After logout, next refresh returns 401 and currentUser should resolve to null
|
||||
await logout()
|
||||
server.use(
|
||||
http.post('*/api/v1/auth/refresh', () => new HttpResponse(null, { status: 401 }))
|
||||
)
|
||||
const second = await currentUser()
|
||||
expect(second).toBeNull()
|
||||
})
|
||||
|
||||
it('createAccount and handleGoogleLogin exist but are not implemented yet', async () => {
|
||||
await expect(createAccount('new@example.com', 'New User', 'pw')).rejects.toBeInstanceOf(Error)
|
||||
await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
23
tests/household.header.test.ts
Normal file
23
tests/household.header.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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 () => {
|
||||
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([])
|
||||
})
|
||||
)
|
||||
|
||||
// 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: {} })
|
||||
expect(res.response.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
36
tests/router.multitenant.test.ts
Normal file
36
tests/router.multitenant.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('router multitenant routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
delete (process.env as any).VUE_APP_MULTITENANT_ENABLED
|
||||
})
|
||||
|
||||
it('includes public routes and legacy flat routes when flag disabled', async () => {
|
||||
const { createAppRouter } = await import('@/router/index')
|
||||
const router = createAppRouter(() => null)
|
||||
const paths = router.getRoutes().map((r) => r.path)
|
||||
expect(paths).toContain('/login')
|
||||
expect(paths).toContain('/create-account')
|
||||
expect(paths).toContain('/welcome')
|
||||
expect(paths).toContain('/invitations/accept')
|
||||
// legacy flat routes
|
||||
expect(paths).toContain('/recipes')
|
||||
expect(paths).toContain('/shopping')
|
||||
})
|
||||
|
||||
it('nests routes under /:householdSlug when flag enabled', async () => {
|
||||
;(process.env as any).VUE_APP_MULTITENANT_ENABLED = 'true'
|
||||
const { createAppRouter } = await import('@/router/index')
|
||||
const router = createAppRouter(() => null)
|
||||
const paths = router.getRoutes().map((r) => r.path)
|
||||
// Public routes still exist
|
||||
expect(paths).toContain('/login')
|
||||
expect(paths).toContain('/create-account')
|
||||
// Nested route example
|
||||
const hasNestedRecipes = paths.some((p) => p === '/:householdSlug/recipes')
|
||||
expect(hasNestedRecipes).toBe(true)
|
||||
// Legacy route should not be present when nested is enabled
|
||||
expect(paths).not.toContain('/recipes')
|
||||
})
|
||||
})
|
||||
62
tests/useAuth.multitenant.test.ts
Normal file
62
tests/useAuth.multitenant.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/api/auth', () => {
|
||||
return {
|
||||
currentUser: vi.fn(async () => ({ id: 42, name: 'Grace Hopper' })),
|
||||
login: vi.fn(async (username: string) => ({ id: 7, name: username })),
|
||||
loginWithPassword: vi.fn(async (email: string) => ({ id: 8, name: email })),
|
||||
logout: vi.fn(async () => {}),
|
||||
createAccount: vi.fn(async () => {
|
||||
throw new Error('createAccount not implemented yet')
|
||||
}),
|
||||
handleGoogleLogin: vi.fn(async () => {
|
||||
throw new Error('handleGoogleLogin not implemented yet')
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { useAuth, loadUser } from '@/composables/useAuth'
|
||||
|
||||
describe('useAuth (multitenant state)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules and state between tests
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('exposes households and activeHousehold state with setters', async () => {
|
||||
const { user, households, activeHousehold, setHouseholds, setActiveHousehold } = useAuth()
|
||||
expect(user.value).toBeNull()
|
||||
expect(households.value).toEqual([])
|
||||
expect(activeHousehold.value).toBeNull()
|
||||
|
||||
const hs = [
|
||||
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
|
||||
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
|
||||
]
|
||||
setHouseholds(hs)
|
||||
expect(households.value.length).toBe(2)
|
||||
setActiveHousehold(hs[1])
|
||||
expect(activeHousehold.value?.slug).toBe('the-johnsons')
|
||||
setActiveHousehold('the-smiths')
|
||||
expect(activeHousehold.value?.slug).toBe('the-smiths')
|
||||
})
|
||||
|
||||
it('supports loading current user, loginWithPassword, and logout clearing user', async () => {
|
||||
const { user, loginWithPassword, logout } = useAuth()
|
||||
// load current user
|
||||
await loadUser()
|
||||
expect(user.value?.name).toBe('Grace Hopper')
|
||||
// login with email/password
|
||||
await loginWithPassword('user@example.com', 'pw')
|
||||
expect(user.value?.name).toBe('user@example.com')
|
||||
// logout clears user
|
||||
await logout()
|
||||
expect(user.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps legacy login(username) available for now', async () => {
|
||||
const { user, login } = useAuth()
|
||||
await login('legacy-user')
|
||||
expect(user.value?.name).toBe('legacy-user')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue