Compare commits
7 Commits
831f36e618
...
550d864252
| Author | SHA1 | Date | |
|---|---|---|---|
| 550d864252 | |||
| de07ccac8e | |||
| 853939e8b8 | |||
| 4197df2b2f | |||
| 26a92b3078 | |||
| 53f6a7be73 | |||
| 5eac201d88 |
@@ -1,9 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
|
||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
|
||||
/**
|
||||
* Seam contract between the HTTP client and the rest of the app.
|
||||
* `lib/axios.ts` knows nothing about Pinia, stores, or routing — it
|
||||
* only invokes these callbacks. The bindings plugin
|
||||
* (`plugins/3.axios-bindings.ts`) supplies the runtime closures.
|
||||
*/
|
||||
export interface AxiosBindingsDeps {
|
||||
getActiveOrgId: () => string | null
|
||||
getImpersonationTargetUserId: () => string | null
|
||||
notify: (message: string, level: 'error' | 'warning') => void
|
||||
onAuthFail: () => void
|
||||
onImpersonationRevoked: () => void
|
||||
}
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
@@ -15,94 +25,73 @@ const apiClient: AxiosInstance = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const orgStore = useOrganisationStore()
|
||||
export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
|
||||
client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const orgId = deps.getActiveOrgId()
|
||||
if (orgId)
|
||||
config.headers['X-Organisation-Id'] = orgId
|
||||
|
||||
if (orgStore.activeOrganisationId)
|
||||
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
|
||||
const impersonationTargetUserId = deps.getImpersonationTargetUserId()
|
||||
if (impersonationTargetUserId)
|
||||
config.headers['X-Impersonate-User'] = impersonationTargetUserId
|
||||
|
||||
// Add impersonation header when active
|
||||
// Lazy import to avoid circular dependency with store
|
||||
const impersonationData = sessionStorage.getItem('crewli_impersonation')
|
||||
if (impersonationData) {
|
||||
try {
|
||||
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
|
||||
if (parsed.targetUserId)
|
||||
config.headers['X-Impersonate-User'] = parsed.targetUserId
|
||||
if (import.meta.env.DEV)
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
||||
|
||||
return config
|
||||
},
|
||||
error => { throw error },
|
||||
)
|
||||
|
||||
client.interceptors.response.use(
|
||||
response => {
|
||||
if (import.meta.env.DEV)
|
||||
console.log(`✅ ${response.status} ${response.config.url}`, response.data)
|
||||
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
if (import.meta.env.DEV)
|
||||
console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data)
|
||||
|
||||
const status = error.response?.status
|
||||
|
||||
// Backend revoked the impersonation session mid-flight; the callback
|
||||
// owns the local cleanup and the post-revoke redirect.
|
||||
if (status === 403 && error.response?.data?.impersonation_ended) {
|
||||
deps.onImpersonationRevoked()
|
||||
|
||||
throw error
|
||||
}
|
||||
catch {
|
||||
// Invalid data — ignore
|
||||
|
||||
if (status === 401) {
|
||||
deps.onAuthFail()
|
||||
}
|
||||
else if (status === 403) {
|
||||
deps.notify('You don\'t have permission for this action.', 'error')
|
||||
}
|
||||
else if (status === 404) {
|
||||
deps.notify('The requested item was not found.', 'warning')
|
||||
}
|
||||
else if (status === 422) {
|
||||
const message = error.response?.data?.message
|
||||
if (message && typeof message === 'string')
|
||||
deps.notify(message, 'error')
|
||||
}
|
||||
else if (status === 503) {
|
||||
deps.notify('Service temporarily unavailable. Please try again later.', 'error')
|
||||
}
|
||||
else if (status && status >= 500) {
|
||||
deps.notify('An unexpected error occurred. Please try again later.', 'error')
|
||||
}
|
||||
else if (!error.response) {
|
||||
deps.notify('Unable to connect to the server. Check your internet connection.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV)
|
||||
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
|
||||
|
||||
return config
|
||||
},
|
||||
async error => { throw error },
|
||||
)
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
response => {
|
||||
if (import.meta.env.DEV)
|
||||
console.log(`✅ ${response.status} ${response.config.url}`, response.data)
|
||||
|
||||
return response
|
||||
},
|
||||
async error => {
|
||||
if (import.meta.env.DEV)
|
||||
console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data)
|
||||
|
||||
const status = error.response?.status
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Handle impersonation session expiry
|
||||
if (status === 403 && error.response?.data?.impersonation_ended) {
|
||||
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
|
||||
const { useImpersonationStore } = await import('@/stores/useImpersonationStore')
|
||||
const impersonationStore = useImpersonationStore()
|
||||
|
||||
impersonationStore.clearState()
|
||||
window.location.href = '/platform'
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
// Lazy import to avoid circular dependency
|
||||
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
|
||||
const { useAuthStore } = await import('@/stores/useAuthStore')
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isInitialized)
|
||||
authStore.handleUnauthorized()
|
||||
}
|
||||
else if (status === 403) {
|
||||
notificationStore.show('You don\'t have permission for this action.', 'error')
|
||||
}
|
||||
else if (status === 404) {
|
||||
notificationStore.show('The requested item was not found.', 'warning')
|
||||
}
|
||||
else if (status === 422) {
|
||||
// Show validation message to user; still reject so component onError handlers can react
|
||||
const message = error.response?.data?.message
|
||||
if (message && typeof message === 'string')
|
||||
notificationStore.show(message, 'error')
|
||||
}
|
||||
else if (status === 503) {
|
||||
notificationStore.show('Service temporarily unavailable. Please try again later.', 'error')
|
||||
}
|
||||
else if (status && status >= 500) {
|
||||
notificationStore.show('An unexpected error occurred. Please try again later.', 'error')
|
||||
}
|
||||
else if (!error.response) {
|
||||
// Network error — no response received
|
||||
notificationStore.show('Unable to connect to the server. Check your internet connection.', 'error')
|
||||
}
|
||||
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export { apiClient }
|
||||
|
||||
27
apps/app/src/plugins/3.axios-bindings.ts
Normal file
27
apps/app/src/plugins/3.axios-bindings.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { App } from 'vue'
|
||||
import { apiClient, registerInterceptors } from '@/lib/axios'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useImpersonationStore } from '@/stores/useImpersonationStore'
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore'
|
||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
|
||||
// Numeric prefix `3.` runs after `2.pinia.ts`, so Pinia is active by
|
||||
// the time these store factories resolve. Stores are looked up lazily
|
||||
// inside each callback (not eagerly at plugin-init), which keeps the
|
||||
// seam tolerant of any future plugin-ordering changes.
|
||||
export default function (_: App): void {
|
||||
registerInterceptors(apiClient, {
|
||||
getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
|
||||
getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
|
||||
notify: (message, level) => useNotificationStore().show(message, level),
|
||||
onAuthFail: () => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isInitialized)
|
||||
authStore.handleUnauthorized()
|
||||
},
|
||||
onImpersonationRevoked: () => {
|
||||
useImpersonationStore().clearState()
|
||||
window.location.href = '/platform'
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -574,7 +574,11 @@ Zie §4 voor scope en stappen.
|
||||
enforcement na PR-B), `TECH-WS3-BOUNDARIES-ROUTER-ZONE` (matrix-update
|
||||
wanneer `plugins/1.router/` naar `router/` verhuist). WS-3 lint cleanup
|
||||
+ boundaries enforcement effectief afgerond; volgende WS-3 stap is PR-B
|
||||
(portal merge) zodra WS-6 sessie 2 in main is geland.
|
||||
(portal merge) zodra WS-6 sessie 2 in main is geland. Debt closed in
|
||||
commit `53f6a7b`: `lib/axios.ts` decoupled from stores via
|
||||
`registerInterceptors(client, deps)` callback seam,
|
||||
`plugins/3.axios-bindings.ts` provides the runtime wiring; the four
|
||||
per-line disables are removed.
|
||||
|
||||
**Klaar-criteria:**
|
||||
- `apps/portal/` is verwijderd
|
||||
|
||||
@@ -602,43 +602,65 @@ sprint waard, geen meelift-pad.
|
||||
|
||||
---
|
||||
|
||||
### TECH-AXIOS-STORE-COUPLING — Decouple lib/axios.ts from stores layer
|
||||
### TECH-AXIOS-INTERCEPTOR-TESTS — Coverage voor de vier axios-interceptor scenarios
|
||||
|
||||
**Aanleiding:** WS-3 sessie 1c (eslint-plugin-boundaries enforcement)
|
||||
constateerde dat `apps/app/src/lib/axios.ts` 4 imports heeft uit `stores/`
|
||||
(2 statisch op regel 3-4 voor `useNotificationStore` /
|
||||
`useOrganisationStore`, 2 dynamisch op regel 61, 72 voor
|
||||
`useImpersonationStore` / `useAuthStore` uit 1b-iii). De `lib → stores`
|
||||
edge schendt de layered-architecture matrix. Om sessie 1c on-time te
|
||||
landen zijn de 4 sites gemarkeerd met `eslint-disable-next-line` +
|
||||
verwijzing naar dit backlog-item; de structurele fix is bewust uitgesteld
|
||||
naar een dedicated sessie omdat het architectuurwerk is, geen tooling-
|
||||
cleanup.
|
||||
**Aanleiding:** TECH-AXIOS-STORE-COUPLING (gesloten 2026-05-04, zie
|
||||
`git log --grep=TECH-AXIOS-STORE-COUPLING`) heeft `lib/axios.ts`
|
||||
ontkoppeld van de stores via een `registerInterceptors(client, deps)`
|
||||
seam plus `plugins/3.axios-bindings.ts`. Tijdens de Phase A audit van
|
||||
die sessie bleek dat de vier acceptatie-scenarios geen van alle een
|
||||
test hebben — niet vóór en niet ná de refactor. De refactor is
|
||||
gedragsneutraal (1:1 behoud), dus er is geen regressie geïntroduceerd,
|
||||
maar het blijft een echte coverage-gap die we niet wilden meeniggen
|
||||
in de refactor-sessie zelf: refactor-en-test-toevoeging in dezelfde
|
||||
commit-set vernietigt het vermogen om vast te stellen of de tests pre-
|
||||
of post-refactor gedrag specificeren.
|
||||
|
||||
**Wat:**
|
||||
- Decouple `lib/axios.ts` van stores zodat het puur HTTP-infrastructuur
|
||||
wordt. Twee paden, kies bij refactor:
|
||||
- **Approach 1 (preferred starting point):** `lib/axios.ts` exporteert
|
||||
de axios-instance plus een `registerInterceptors(client, deps)`
|
||||
functie die callbacks accepteert (`onAuthFail`,
|
||||
`onImpersonationDrop`, `getActiveOrgId`, `notify(message, level)`).
|
||||
Een nieuwe `plugins/axios-bindings.ts` (mag `stores` importeren per
|
||||
matrix) roept `registerInterceptors` aan bij app-init met closures
|
||||
over de stores.
|
||||
- **Approach 2 (fallback):** event-bus / callback registry; axios.ts
|
||||
emit-eert semantische events (`auth-failed`, `needs-org-header`,
|
||||
`notify-error`) en `plugins/axios-bindings.ts` subscribet.
|
||||
- Verwijder alle 4 `eslint-disable-next-line` comments uit
|
||||
`lib/axios.ts`.
|
||||
- Tests moeten dekken: X-Organisation-Id header injection, 401/403
|
||||
logout flow, impersonation revocation flow, error toast op 4xx/5xx.
|
||||
- Optioneel: meteen ook de static/dynamic import-split in axios.ts
|
||||
uniformeren (nu inconsistent om legacy 1b-iii-redenen).
|
||||
**Wat:** Vitest-tests die de interceptors echt laden (niet via
|
||||
`vi.mock('@/lib/axios')`) en assertions doen tegen een gemockte
|
||||
HTTP-laag (bv. `axios-mock-adapter`). De vier scenarios:
|
||||
|
||||
**Prioriteit:** Middel — geen blokker voor andere workstreams, maar elke
|
||||
maand dat dit blijft staan is een vlek op de boundaries-enforcement
|
||||
geloofwaardigheid. Aanbevolen: meelift met de eerste WS-3 PR die `lib/`
|
||||
of `plugins/` raakt, of een dedicated 2-3 uur sessie na WS-6 sluiting.
|
||||
1. **`X-Organisation-Id` header-injection.** Set een actieve
|
||||
organisatie via `useOrganisationStore`, registreer interceptors
|
||||
met de bindings-deps, fire een outbound request, assert dat de
|
||||
header de actieve ULID bevat. Test ook het null-pad: geen
|
||||
actieve organisatie → header niet gezet.
|
||||
2. **401 → auth-fail flow.** Mock de response op 401, registreer
|
||||
interceptors waarbij `onAuthFail` een spy is, assert dat de
|
||||
spy wordt aangeroepen exact wanneer `useAuthStore.isInitialized`
|
||||
true is en niet gedurende de eerste `/auth/me`-probe (de
|
||||
race-conditie die sessie 1b-iii repareerde — die guard moet
|
||||
blijven werken).
|
||||
3. **403 + `impersonation_ended` → revocation flow.** Mock de
|
||||
response op `403` met body `{ impersonation_ended: true }`,
|
||||
registreer interceptors waarbij `onImpersonationRevoked` een
|
||||
spy is, assert dat de spy precies één keer wordt aangeroepen
|
||||
en dat de generieke 403-toast NIET wordt geactiveerd (dat was
|
||||
een early-return in `axios.ts`, makkelijk per ongeluk te
|
||||
breken bij een toekomstige refactor).
|
||||
4. **4xx/5xx error toast.** Mock 403/404/422/503/5xx/network-error
|
||||
responses, assert dat `notify` de juiste boodschap + level
|
||||
krijgt voor elk geval. 422 met body-`message` moet het
|
||||
server-bericht doorgeven; 422 zonder `message` mag geen toast
|
||||
triggeren (huidige gedrag).
|
||||
|
||||
**Hoe niet:** geen unit-tests die het hele `lib/axios.ts`-module
|
||||
mocken — die testen de seam niet, alleen het mock-framework. De
|
||||
toegevoegde waarde zit in een test-fixture die de echte
|
||||
`registerInterceptors` aanroept met een echte axios-instance die
|
||||
tegen `axios-mock-adapter` praat.
|
||||
|
||||
**Niet in scope:** integratietests die echt door Pinia heen lopen.
|
||||
De seam is bewust callback-injectie zodat tests met spy-callbacks
|
||||
volstaan. Wie de full-stack flow wil dekken (zie `App.vue`'s
|
||||
session-init dance) doet dat met E2E in een latere sprint.
|
||||
|
||||
**Prioriteit:** Middel — de gap is reëel maar niet blokkerend. De
|
||||
6 bestaande `vi.mock('@/lib/axios')`-tests vangen "named export
|
||||
werkt nog" af, en de vier flows zijn manueel via de browser
|
||||
verifieerbaar. Aanbevolen moment: eerste WS-3 PR die `axios.ts`
|
||||
of de bindings-plugin opnieuw raakt, of opvolger van TECH-APP-VITEST
|
||||
als bredere harness-uitbreiding.
|
||||
|
||||
---
|
||||
|
||||
|
||||
367
dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md
Normal file
367
dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# TECH-AXIOS-STORE-COUPLING — Phase A audit
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Branch:** `refactor/axios-store-coupling`
|
||||
**Scope:** read-only audit. No code changed in this phase.
|
||||
**Goal:** make `apps/app/src/lib/axios.ts` a pure HTTP module by routing
|
||||
its four `stores/` references through a callback seam wired up from a
|
||||
new `plugins/` plugin. Phase A inventories what exists today; Phase B
|
||||
asks Bert to resolve four open questions; Phase C implements.
|
||||
|
||||
---
|
||||
|
||||
## A.1 — Touchpoint inventory in `apps/app/src/lib/axios.ts`
|
||||
|
||||
The file is 109 lines on `main` at `831f36e`. The four boundary-rule
|
||||
disables are at lines **3, 5, 63, 75** (single-line drift versus the
|
||||
BACKLOG note of "regels 3-4, 61, 72" — caused by minor formatting
|
||||
changes since 1c, semantically identical).
|
||||
|
||||
### Touchpoint 1 — `useOrganisationStore` (static)
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Disable line | 3 |
|
||||
| Import line | 4 (`import { useOrganisationStore } from '@/stores/useOrganisationStore'`) |
|
||||
| Invocation line | 20 (`const orgStore = useOrganisationStore()`) |
|
||||
| Interceptor | request |
|
||||
| Access | read state — `orgStore.activeOrganisationId` (string \| null) |
|
||||
| Semantic purpose | inject the active organisation ULID into the `X-Organisation-Id` request header so the backend can scope the query to the active tenant |
|
||||
| Shape needed in `Deps` | `getActiveOrgId: () => string \| null` |
|
||||
|
||||
### Touchpoint 2 — `useNotificationStore` (static)
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Disable line | 5 |
|
||||
| Import line | 6 (`import { useNotificationStore } from '@/stores/useNotificationStore'`) |
|
||||
| Invocation line | 59 (`const notificationStore = useNotificationStore()`) |
|
||||
| Interceptor | response (error path) |
|
||||
| Access | calls `.show(message, level)` action |
|
||||
| Semantic purpose | toast a message via `v-snackbar` for 403 / 404 / 422 / 503 / 5xx / network errors |
|
||||
| Shape needed in `Deps` | `notify: (message: string, level: 'error' \| 'warning') => void` (the only two levels used in the current code) |
|
||||
|
||||
### Touchpoint 3 — `useImpersonationStore` (dynamic)
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Disable line | 63 |
|
||||
| Import line | 64 (`const { useImpersonationStore } = await import('@/stores/useImpersonationStore')`) |
|
||||
| Invocation line | 65 (`const impersonationStore = useImpersonationStore()`) |
|
||||
| Interceptor | response (error path), under `status === 403 && error.response?.data?.impersonation_ended` |
|
||||
| Access | calls `.clearState()` then `window.location.href = '/platform'` |
|
||||
| Semantic purpose | when the backend revokes an impersonation session mid-flight (403 with `impersonation_ended`), drop the local impersonation state and full-page-redirect back to `/platform` so the original admin session restores |
|
||||
| Shape needed in `Deps` | `onImpersonationRevoked: () => void` (the redirect is the side-effect of clearing state; whether the callback owns the redirect or just the clear is a Phase B Q2-adjacent question — see notes) |
|
||||
|
||||
### Touchpoint 4 — `useAuthStore` (dynamic)
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Disable line | 75 |
|
||||
| Import line | 76 (`const { useAuthStore } = await import('@/stores/useAuthStore')`) |
|
||||
| Invocation line | 77 (`const authStore = useAuthStore()`) |
|
||||
| Interceptor | response (error path), under `status === 401` |
|
||||
| Access | reads `.isInitialized`; if true, calls `.handleUnauthorized()` |
|
||||
| Semantic purpose | on 401, if the auth store has finished its initial `/auth/me` probe, clear local auth state and redirect to `/login`. The `isInitialized` guard exists because the initial `/auth/me` call itself returns 401 for unauthenticated visitors and that is *not* a logout-trigger |
|
||||
| Shape needed in `Deps` | `onAuthFail: () => void` — the callback closure should encapsulate both the `isInitialized` check and the `handleUnauthorized()` call (see Phase B Q2). Alternatively, two callbacks (`isAuthInitialized: () => boolean` + `onAuthFail: () => void`) but that's a leakier seam. |
|
||||
|
||||
### Non-store behavior in `axios.ts` (preserve, do not refactor)
|
||||
|
||||
- **Lines 1–2:** `axios` import + named-type imports.
|
||||
- **Lines 8–16:** `apiClient` factory — `axios.create({ baseURL, withCredentials, headers, timeout: 30000 })`. Pure config, no store deps.
|
||||
- **Lines 27–37 (request interceptor):** reads `sessionStorage.getItem('crewli_impersonation')`, parses JSON, sets `X-Impersonate-User` header. **This is *not* a boundary violation** — it touches `sessionStorage` directly, not a store. It is conceptually parallel to touchpoint 1 but already store-free, so it stays as-is in Phase C unless Bert prefers a parallel callback (`getImpersonationTargetId: () => string | null`) for symmetry. Recommendation: leave alone in Phase C — the seam should only encapsulate the store-coupled paths. Refactoring this is scope creep.
|
||||
- **Line 40, 50, 56:** `console.log/error` under `import.meta.env.DEV` — pure logging.
|
||||
- **Lines 73, 75–80, 82, 85, 88–95, 97, 99–101:** the rest of the response error mapping — same status-code branching, all toast-via-`notify` once touchpoint 2 is callback-ified.
|
||||
- **Line 108:** `export { apiClient }` — single named export. **Critical:** 30 importers across `apps/app/src/` consume `import { apiClient } from '@/lib/axios'`. The named export must keep working post-refactor. Phase C only adds a *new* `registerInterceptors` export; nothing is renamed or removed.
|
||||
|
||||
### Singleton vs. factory
|
||||
|
||||
`apiClient` is a top-level singleton (created at module-eval time). The
|
||||
two interceptor registrations execute as a side-effect of the first
|
||||
import. Phase C must split this: the singleton stays, but interceptor
|
||||
registration moves to an explicit `registerInterceptors(client, deps)`
|
||||
call invoked from the new bindings plugin. Until that call runs, the
|
||||
client is interceptor-less — fine, because the bindings plugin runs in
|
||||
the plugin-init phase before any composable or component issues an
|
||||
HTTP call (Pinia + the bindings plugin both initialize during
|
||||
`registerPlugins(app)` in `main.ts`, before `app.mount`).
|
||||
|
||||
---
|
||||
|
||||
## A.2 — Plugins ordering and proposed filename
|
||||
|
||||
### How plugins load
|
||||
|
||||
`main.ts` calls `registerPlugins(app)` from
|
||||
`@core/utils/plugins.ts`. The helper is a Vuexy convention that uses
|
||||
`import.meta.glob(['../../plugins/*.{ts,js}', '../../plugins/*/index.{ts,js}'], { eager: true })`,
|
||||
collects the keys, **alphabetically sorts the path strings**, and calls
|
||||
each module's `default(app)` in that order. There is no manual
|
||||
registration in `main.ts` — drop a file in `src/plugins/` and it gets
|
||||
picked up.
|
||||
|
||||
### Current alphabetical load order
|
||||
|
||||
| Sort key (path string) | File | Role | Pinia ready after this? |
|
||||
| --- | --- | --- | --- |
|
||||
| `../../plugins/1.router/index.ts` | `1.router/index.ts` | `app.use(router)` + guards | no |
|
||||
| `../../plugins/2.pinia.ts` | `2.pinia.ts` | `createPinia()` + `app.use(store)` | **yes (this is where it happens)** |
|
||||
| `../../plugins/iconify/index.ts` | `iconify/index.ts` | iconify registration | yes |
|
||||
| `../../plugins/layouts.ts` | `layouts.ts` | `createLayouts()` | yes |
|
||||
| `../../plugins/vuetify/index.ts` | `vuetify/index.ts` | Vuetify registration | yes |
|
||||
| `../../plugins/webfontloader.ts` | `webfontloader.ts` | webfont preload | yes |
|
||||
|
||||
Ordering invariant: anything that needs Pinia stores must sort
|
||||
**after** `2.pinia.ts`. The numeric prefix is the established convention
|
||||
for explicit ordering (router=1, pinia=2). Anything without a numeric
|
||||
prefix sorts after the numeric ones because digits sort before letters
|
||||
in standard lexicographic comparison.
|
||||
|
||||
### Proposed filename
|
||||
|
||||
**`apps/app/src/plugins/3.axios-bindings.ts`**
|
||||
|
||||
Rationale:
|
||||
- `3.` makes the post-Pinia ordering explicit and human-readable
|
||||
alongside the existing `1.router` / `2.pinia` numeric prefixes.
|
||||
Future readers reading top-to-bottom in a directory listing see
|
||||
the load sequence at a glance.
|
||||
- A single `.ts` file (not a directory + `index.ts`) — the wiring is
|
||||
~30 lines of closure plumbing, no internal modules to split.
|
||||
- Name `axios-bindings` matches the BACKLOG entry's wording. "Bindings"
|
||||
reads as "bind axios interceptors to store callbacks" — the file's
|
||||
job is exactly that wiring.
|
||||
|
||||
If Bert prefers a non-numeric name (e.g. just `axios-bindings.ts`,
|
||||
sorting after the numeric files), that works too — Pinia is still
|
||||
already up. Numeric-prefix is recommended for parity with the existing
|
||||
router/pinia ordering convention. **Open as Phase B Q4.**
|
||||
|
||||
---
|
||||
|
||||
## A.3 — Test coverage matrix for the four acceptance scenarios
|
||||
|
||||
`apps/app` runs Vitest against 49 tests across 10 files (sanity smoke
|
||||
in `tests/`, plus `__tests__/` colocated under `layouts/`,
|
||||
`composables/api/`, and `components/form-failures/`).
|
||||
|
||||
| Acceptance scenario | Existing test? | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1. `X-Organisation-Id` header injection | **No** | No test exercises the request interceptor. |
|
||||
| 2. 401 → `handleUnauthorized()` flow | **No** | `useAuthStore` has no colocated tests; no interceptor test. |
|
||||
| 3. 403 + `impersonation_ended` revocation | **No** | `useImpersonationStore` has no colocated tests; no interceptor test. |
|
||||
| 4. 4xx/5xx error toast via `useNotificationStore` | **No** | `useNotificationStore` has no colocated tests; no interceptor test. |
|
||||
|
||||
### How `@/lib/axios` is used in existing tests
|
||||
|
||||
All 6 existing axios-touching test files use `vi.mock('@/lib/axios', () => ({ apiClient: { ... } }))` to swap the entire module out. None of them load real interceptors. List:
|
||||
|
||||
- `src/composables/api/__tests__/useFormFailures.spec.ts`
|
||||
- `src/components/form-failures/__tests__/FormFailuresTable.spec.ts`
|
||||
- `src/components/form-failures/__tests__/DismissFailureDialog.spec.ts`
|
||||
- `src/components/form-failures/__tests__/FormFailureDetail.spec.ts`
|
||||
- `src/components/form-failures/__tests__/RetryFailureDialog.spec.ts`
|
||||
- `src/components/form-failures/__tests__/ResolveFailureDialog.spec.ts`
|
||||
|
||||
These tests will keep passing post-refactor regardless of how
|
||||
`registerInterceptors` looks — they replace the module wholesale.
|
||||
|
||||
### Stores tested directly?
|
||||
|
||||
No — `apps/app/src/stores/__tests__/` does not exist. None of the four
|
||||
stores referenced from `axios.ts` has a unit test. The Vitest harness
|
||||
(`tests/sanity.spec.ts`) was just landed in WS-6 sessie 3b
|
||||
(TECH-APP-VITEST closure); the colocated `__tests__/` folders only
|
||||
cover layouts, form-failures components, and `useFormFailures`.
|
||||
|
||||
### Recommendation for Phase B Q3
|
||||
|
||||
Default to **option C from the brief: add as follow-up backlog entry
|
||||
`TECH-AXIOS-INTERCEPTOR-TESTS`**, do not add tests in this session.
|
||||
|
||||
Reasoning:
|
||||
- All 4 acceptance scenarios are untested today, so the refactor is
|
||||
not regressing existing coverage.
|
||||
- Building an interceptor-test fixture that loads the real
|
||||
`apiClient`, registers `registerInterceptors` with mocked deps,
|
||||
and asserts on `axios-mock-adapter` (or `nock`-style) intercepted
|
||||
requests is genuinely >2h of infrastructure work in a project
|
||||
that has only had Vitest for a few sessies. The brief's default
|
||||
scope rule applies.
|
||||
- The 6 module-mocked tests give us a regression net for "the
|
||||
`apiClient` named export still works": if any `.get`/`.post` chain
|
||||
breaks, those component tests fall over. Combined with manual
|
||||
smoke-testing the four flows in dev, that's adequate for this
|
||||
session.
|
||||
|
||||
If Bert overrules this and wants tests in-session, the smallest
|
||||
viable path is option B targeted on **just touchpoint 1** (`X-Organisation-Id`
|
||||
header injection) — it's the cheapest to test (request-time, no
|
||||
mocked window navigation) and the most boring to verify by hand,
|
||||
making it the highest-value test-per-effort. Touchpoints 2–4
|
||||
involve `window.location.href` mutations that are awkward in
|
||||
Vitest+happy-dom.
|
||||
|
||||
---
|
||||
|
||||
## A.4 — Consumer audit for `@/lib/axios`
|
||||
|
||||
```bash
|
||||
grep -rln "from '@/lib/axios'" apps/app/src/ | wc -l # 30
|
||||
grep -rln "vi.mock('@/lib/axios'" apps/app/src/ | wc -l # 6
|
||||
grep -rln "import('@/lib/axios')" apps/app/src/ | wc -l # 1 (AccountTab.vue, dynamic)
|
||||
```
|
||||
|
||||
### Group 1 — composables/api (22 files)
|
||||
|
||||
All 22 files in `src/composables/api/use*.ts` import `{ apiClient }` and
|
||||
use `.get` / `.post` / `.patch` / `.put` / `.delete`. Vanilla axios usage.
|
||||
|
||||
### Group 2 — stores (2 files)
|
||||
|
||||
`src/stores/useAuthStore.ts` and `src/stores/useImpersonationStore.ts`
|
||||
both import `{ apiClient }`. (Other stores don't talk to axios.)
|
||||
**Important:** these are the two stores that are ALSO referenced from
|
||||
`axios.ts` via dynamic import — that's the cycle the Vite warnings
|
||||
flag. Removing those dynamic imports from `axios.ts` resolves the
|
||||
cycle automatically.
|
||||
|
||||
### Group 3 — components / pages (6 files)
|
||||
|
||||
- `components/platform/ImpersonateDialog.vue`
|
||||
- `components/account-settings/AccountTab.vue` (dynamic import)
|
||||
- `components/common/ImageUploadField.vue`
|
||||
- `pages/login.vue`
|
||||
- `pages/forgot-password.vue`
|
||||
- `pages/reset-password.vue`
|
||||
- `pages/verify-email-change.vue`
|
||||
|
||||
These violate the project rule "never import axios directly in a component (use a composable)" — that's a separate cleanup
|
||||
not in this session's scope. Listing for completeness.
|
||||
|
||||
### Group 4 — tests that mock the module (6 files)
|
||||
|
||||
Listed in A.3. They use `vi.mock` and replace `apiClient` with a stub;
|
||||
they don't care about the interceptor wiring.
|
||||
|
||||
### Breaking-change surface
|
||||
|
||||
**Zero.** Every consumer reads `{ apiClient }` from
|
||||
`@/lib/axios`. Phase C only:
|
||||
- Adds a new export `registerInterceptors`.
|
||||
- Adds a new export `Deps` type (or hosts it in `@/types/` per Phase B Q2).
|
||||
|
||||
Existing imports keep working unchanged. No fanout.
|
||||
|
||||
---
|
||||
|
||||
## A.5 — Vite mixed-import warning confirmation
|
||||
|
||||
Ran `pnpm build` from `apps/app/` on `main`@`831f36e`. Both warnings
|
||||
reproduce as the BACKLOG describes. The relevant excerpt:
|
||||
|
||||
```
|
||||
[plugin vite:reporter]
|
||||
(!) /…/apps/app/src/stores/useAuthStore.ts is dynamically imported by
|
||||
/…/apps/app/src/lib/axios.ts but also statically imported by
|
||||
/…/apps/app/src/App.vue, /…/AccountTab.vue, /…/SecurityTab.vue,
|
||||
/…/EventMetricCards.vue, /…/EventTabsNav.vue,
|
||||
/…/OrganisationSwitcher.vue, … (≈30 callers total),
|
||||
dynamic import will not move module into another chunk.
|
||||
|
||||
[plugin vite:reporter]
|
||||
(!) /…/apps/app/src/stores/useImpersonationStore.ts is dynamically
|
||||
imported by /…/apps/app/src/lib/axios.ts but also statically
|
||||
imported by /…/apps/app/src/App.vue, /…/ImpersonateDialog.vue,
|
||||
/…/ImpersonationBanner.vue, /…/DefaultLayoutWithVerticalNav.vue,
|
||||
dynamic import will not move module into another chunk.
|
||||
```
|
||||
|
||||
Build still succeeds (10.96s, 49 tests pass). The warnings are
|
||||
diagnostic only, but they are an explicit acceptance criterion for
|
||||
Phase C: removing the two `await import('@/stores/...')` calls in
|
||||
`axios.ts` should drop both warnings to zero. If either warning
|
||||
persists post-refactor, it means another file in the codebase is
|
||||
also doing `await import('@/stores/useAuthStore')` or
|
||||
`'.../useImpersonationStore'`. Quick `grep -rn "await import.*useAuthStore\|await import.*useImpersonationStore" apps/app/src/`
|
||||
during Phase C will confirm; expectation is that `axios.ts` is the
|
||||
only offender.
|
||||
|
||||
---
|
||||
|
||||
## A.6 — Open questions for Bert (Phase B sign-off)
|
||||
|
||||
### Q1. Approach 1 (callback-injection) or Approach 2 (event-bus)?
|
||||
|
||||
**Recommendation: Approach 1 (callback-injection).**
|
||||
|
||||
The four touchpoints map cleanly to four callbacks (or three +
|
||||
one read), all single-subscriber, all synchronous in their
|
||||
domain (the heavy work is the callee's, not the seam's). No
|
||||
case where multiple subscribers would want to react to the same
|
||||
event. No case where the subscriber needs to register/unregister
|
||||
dynamically at runtime. An event bus would add a second
|
||||
indirection, a stringly-typed event name surface, and break
|
||||
TypeScript's ability to enforce that every event has exactly the
|
||||
deps it needs.
|
||||
|
||||
Approach 2 would only beat Approach 1 if we wanted the bindings
|
||||
plugin to subscribe to events emitted from places other than
|
||||
`axios.ts`. We don't.
|
||||
|
||||
### Q2. Where should the typed `Deps` interface live?
|
||||
|
||||
**Recommendation: inside `lib/axios.ts`, exported.**
|
||||
|
||||
Rationale:
|
||||
- The `Deps` shape is the *contract of `lib/axios.ts`*. It belongs
|
||||
to the module that consumes it. The bindings plugin imports it
|
||||
to know what to provide; that's a normal `import type` flow and
|
||||
doesn't break the boundaries matrix (`plugins → lib` is allowed).
|
||||
- Hoisting it to `src/types/` would be more "architecturally pure"
|
||||
but adds a hop with no payoff: nothing else in the codebase
|
||||
consumes this contract, and `types/` is for shared
|
||||
domain/value-object types, not for module-internal interfaces.
|
||||
- The architectural test that matters is "does `lib/axios.ts`
|
||||
import from `@/stores/...`?" — that's what the boundaries rule
|
||||
enforces. Where the type lives doesn't affect that.
|
||||
|
||||
If Bert prefers the `types/`-hoist for consistency, no objection;
|
||||
the diff cost is one extra file. State preference in Phase B.
|
||||
|
||||
### Q3. Test scope for this session.
|
||||
|
||||
**Recommendation: option C — add follow-up backlog entry
|
||||
`TECH-AXIOS-INTERCEPTOR-TESTS`, no new tests in this session.**
|
||||
|
||||
Reasoning in A.3. All four scenarios are untested today; the refactor
|
||||
preserves behavior 1:1; the 6 existing module-mock tests are an
|
||||
adequate "named-export still works" net; building real
|
||||
interceptor-fixture infrastructure is >2h.
|
||||
|
||||
If Bert prefers option B (add tests for touchpoint 1 only — the
|
||||
`X-Organisation-Id` header injection — as the cheapest meaningful
|
||||
test), that's a reasonable middle ground; estimate ~1h.
|
||||
|
||||
### Q4. Confirm plugin filename and ordering.
|
||||
|
||||
**Recommendation: `apps/app/src/plugins/3.axios-bindings.ts`.**
|
||||
|
||||
Numeric prefix `3.` for parity with `1.router/` and `2.pinia.ts`,
|
||||
making the post-Pinia load order explicit. Alternative: drop the
|
||||
prefix (`axios-bindings.ts`) — it would still sort after the
|
||||
numeric files because digits precede letters lexicographically.
|
||||
Numeric is preferred for readability.
|
||||
|
||||
---
|
||||
|
||||
## A.7 — Deliverable
|
||||
|
||||
This file is `dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md`. Phase A
|
||||
ends here. **No code edits, no `BACKLOG.md` change, no plugin file,
|
||||
no `axios.ts` change yet.** Awaiting Phase B sign-off on Q1–Q4.
|
||||
|
||||
Branch: `refactor/axios-store-coupling`.
|
||||
|
||||
Phase A commit message:
|
||||
`docs(refactor): audit axios↔store coupling for decoupling work`.
|
||||
Reference in New Issue
Block a user