@mapomodule/core 2.0.0-beta.5 → 2.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/index.d.mts +1 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.mjs +1 -0
  5. package/dist/module.d.mts +1 -0
  6. package/dist/module.json +1 -1
  7. package/dist/runtime/__tests__/plugins/00.fetch.real.spec.d.ts +2 -0
  8. package/dist/runtime/__tests__/plugins/00.fetch.real.spec.d.ts.map +1 -0
  9. package/dist/runtime/__tests__/plugins/01.init.server.spec.d.ts +2 -0
  10. package/dist/runtime/__tests__/plugins/01.init.server.spec.d.ts.map +1 -0
  11. package/dist/runtime/__tests__/utils/fetchError.spec.d.ts +2 -0
  12. package/dist/runtime/__tests__/utils/fetchError.spec.d.ts.map +1 -0
  13. package/dist/runtime/plugins/00.fetch.d.ts.map +1 -1
  14. package/dist/runtime/plugins/00.fetch.js +10 -6
  15. package/dist/runtime/plugins/01.init.server.d.ts +2 -1
  16. package/dist/runtime/plugins/01.init.server.d.ts.map +1 -1
  17. package/dist/runtime/plugins/01.init.server.js +4 -6
  18. package/dist/runtime/utils/fetchError.d.ts +29 -0
  19. package/dist/runtime/utils/fetchError.d.ts.map +1 -0
  20. package/dist/runtime/utils/fetchError.js +12 -0
  21. package/package.json +5 -5
  22. package/src/index.ts +5 -0
  23. package/src/runtime/__tests__/plugins/00.fetch.real.spec.ts +124 -0
  24. package/src/runtime/__tests__/plugins/00.fetch.spec.ts +3 -1
  25. package/src/runtime/__tests__/plugins/01.init.server.spec.ts +94 -0
  26. package/src/runtime/__tests__/utils/fetchError.spec.ts +97 -0
  27. package/src/runtime/plugins/00.fetch.ts +13 -6
  28. package/src/runtime/plugins/01.init.server.ts +10 -6
  29. package/src/runtime/utils/fetchError.ts +50 -0
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@ export { ApiResponse, AuthToken, CoreCookieEnum, Credentials, CrudConfig, CrudOp
2
2
  export { MapoApiLayer, MapoFacade } from '../dist/runtime/composables/useMapo.js';
3
3
  export { RoutePermissions, useCanAccessRoute } from '../dist/runtime/auth/useCanAccessRoute.js';
4
4
  export { useMapoFetch } from '../dist/runtime/utils/useMapoFetch.js';
5
+ export { getErrorData, getErrorDetail, getErrorStatus } from '../dist/runtime/utils/fetchError.js';
5
6
 
6
7
  interface MapoOptions {
7
8
  authLoginUrl?: string;
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export type * from "./module";
2
2
  export { MultipartPolicyEnum, CoreCookieEnum } from "./runtime/types";
3
3
  export { useCanAccessRoute } from "./runtime/auth/useCanAccessRoute";
4
4
  export { useMapoFetch } from "./runtime/utils/useMapoFetch";
5
+ export { getErrorData, getErrorStatus, getErrorDetail, } from "./runtime/utils/fetchError";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,UAAU,CAAC;AAC9B,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,UAAU,CAAC;AAC9B,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EACL,YAAY,EACZ,cAAc,EACd,cAAc,GACf,MAAM,4BAA4B,CAAC"}
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
1
  export { CoreCookieEnum, MultipartPolicyEnum } from '../dist/runtime/types.js';
2
2
  export { useCanAccessRoute } from '../dist/runtime/auth/useCanAccessRoute.js';
3
3
  export { useMapoFetch } from '../dist/runtime/utils/useMapoFetch.js';
4
+ export { getErrorData, getErrorDetail, getErrorStatus } from '../dist/runtime/utils/fetchError.js';
package/dist/module.d.mts CHANGED
@@ -4,6 +4,7 @@ export { ApiResponse, AuthToken, CoreCookieEnum, Credentials, CrudConfig, CrudOp
4
4
  export { MapoApiLayer, MapoFacade } from '../dist/runtime/composables/useMapo.js';
5
5
  export { RoutePermissions } from '../dist/runtime/auth/useCanAccessRoute.js';
6
6
  import '../dist/runtime/utils/useMapoFetch.js';
7
+ import '../dist/runtime/utils/fetchError.js';
7
8
 
8
9
  declare const _default: NuxtModule<MapoOptions, MapoOptions, false>;
9
10
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mapomodule/core",
3
3
  "configKey": "mapoCore",
4
- "version": "2.0.0-beta.5",
4
+ "version": "2.0.0-beta.6",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=00.fetch.real.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"00.fetch.real.spec.d.ts","sourceRoot":"","sources":["../../../../src/runtime/__tests__/plugins/00.fetch.real.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=01.init.server.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"01.init.server.spec.d.ts","sourceRoot":"","sources":["../../../../src/runtime/__tests__/plugins/01.init.server.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fetchError.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetchError.spec.d.ts","sourceRoot":"","sources":["../../../../src/runtime/__tests__/utils/fetchError.spec.ts"],"names":[],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"00.fetch.d.ts","sourceRoot":"","sources":["../../../src/runtime/plugins/00.fetch.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;GAWG;;;;;;;;AACH,wBA6DG"}
1
+ {"version":3,"file":"00.fetch.d.ts","sourceRoot":"","sources":["../../../src/runtime/plugins/00.fetch.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;GAWG;;;;;;;;AACH,wBAmEG"}
@@ -2,6 +2,7 @@ import { ref, computed } from "vue";
2
2
  import {
3
3
  defineNuxtPlugin,
4
4
  navigateTo,
5
+ useRequestHeaders,
5
6
  useRoute,
6
7
  useRuntimeConfig
7
8
  } from "nuxt/app";
@@ -33,19 +34,22 @@ export default defineNuxtPlugin({
33
34
  };
34
35
  const pending = ref(0);
35
36
  const mapoFetchLoading = computed(() => pending.value > 0);
37
+ const forwardedCookie = import.meta.server ? useRequestHeaders(["cookie"]).cookie : void 0;
36
38
  const mapoFetch = $fetch.create({
37
- onRequest() {
39
+ onRequest({ options }) {
38
40
  pending.value++;
41
+ if (forwardedCookie) {
42
+ const headers = new Headers(
43
+ options.headers
44
+ );
45
+ if (!headers.has("cookie")) headers.set("cookie", forwardedCookie);
46
+ options.headers = headers;
47
+ }
39
48
  },
40
49
  onResponse({ response, request }) {
41
50
  pending.value--;
42
51
  handle(response.status, String(request));
43
52
  },
44
- onResponseError({ response, request, error }) {
45
- pending.value--;
46
- handle(response?.status ?? 0, String(request));
47
- return Promise.reject(error);
48
- },
49
53
  onRequestError() {
50
54
  pending.value--;
51
55
  }
@@ -7,7 +7,8 @@
7
7
  * - If the HttpOnly session cookie is present, calls the configured `userInfoApi`
8
8
  * (forwarding request cookies) to restore the authenticated user in the auth store.
9
9
  *
10
- * On session restore failure, it resets auth state and clears the session cookie.
10
+ * On session restore failure it resets auth state, and clears the session cookie only
11
+ * when the backend rejected the session (401/403) — never on a transport error.
11
12
  */
12
13
  declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
13
14
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"01.init.server.d.ts","sourceRoot":"","sources":["../../../src/runtime/plugins/01.init.server.ts"],"names":[],"mappings":"AAeA;;;;;;;;;;GAUG;;AACH,wBAmCG"}
1
+ {"version":3,"file":"01.init.server.d.ts","sourceRoot":"","sources":["../../../src/runtime/plugins/01.init.server.ts"],"names":[],"mappings":"AAcA;;;;;;;;;;;GAWG;;AACH,wBAuCG"}
@@ -2,8 +2,7 @@ import {
2
2
  defineNuxtPlugin,
3
3
  useRuntimeConfig,
4
4
  useCookie,
5
- useRequestHeaders,
6
- useRequestURL
5
+ useRequestHeaders
7
6
  } from "nuxt/app";
8
7
  import { setActivePinia } from "pinia";
9
8
  import { useAuthStore } from "@mapomodule/store/runtime/stores/auth";
@@ -23,15 +22,14 @@ export default defineNuxtPlugin({
23
22
  if (!sessionCookie.value) return;
24
23
  try {
25
24
  const headers = useRequestHeaders(["cookie"]);
26
- const url = useRequestURL();
27
- const absolute = `${url.protocol}//${url.host}${userInfoApi}`;
28
- const user = await nuxtApp.$mapoFetch(absolute, { method: "GET", headers });
25
+ const user = await nuxtApp.$mapoFetch(userInfoApi, { method: "GET", headers });
29
26
  authStore.setUser(user);
30
27
  } catch (e) {
31
28
  console.error(`[mapo] Cannot restore session from cookie:
32
29
  ${e}`);
33
30
  authStore.reset();
34
- sessionCookie.value = null;
31
+ const status = e?.status ?? e?.response?.status;
32
+ if (status === 401 || status === 403) sessionCookie.value = null;
35
33
  }
36
34
  }
37
35
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Helpers to read the backend payload out of a failed `$mapoFetch` call.
3
+ *
4
+ * `ofetch` throws a `FetchError` that exposes the parsed response body as
5
+ * `error.data` (and `error.response._data`) — NOT as `error.response.data`.
6
+ * Reading the wrong path silently yields `undefined`, which turns every
7
+ * backend validation message into a generic "something went wrong".
8
+ * Use these helpers instead of destructuring the error by hand.
9
+ */
10
+ /**
11
+ * Parsed response body of a failed request, or `undefined` when the request
12
+ * failed before a response was received (network error, abort…).
13
+ *
14
+ * @example
15
+ * try { await crud.create(model) }
16
+ * catch (err) {
17
+ * const data = getErrorData<{ title?: string[] }>(err)
18
+ * errors.value = data ?? {}
19
+ * }
20
+ */
21
+ export declare function getErrorData<T = Record<string, unknown>>(error: unknown): T | undefined;
22
+ /** HTTP status of a failed request, or `0` when no response was received. */
23
+ export declare function getErrorStatus(error: unknown): number;
24
+ /**
25
+ * `detail` message returned by the backend (DRF convention), when present.
26
+ * Falls back to `undefined` so callers can substitute a translated default.
27
+ */
28
+ export declare function getErrorDetail(error: unknown): string | undefined;
29
+ //# sourceMappingURL=fetchError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetchError.d.ts","sourceRoot":"","sources":["../../../src/runtime/utils/fetchError.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAUH;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtD,KAAK,EAAE,OAAO,GACb,CAAC,GAAG,SAAS,CAGf;AAED,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGrD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAGjE"}
@@ -0,0 +1,12 @@
1
+ export function getErrorData(error) {
2
+ const e = error;
3
+ return e?.data ?? e?.response?._data;
4
+ }
5
+ export function getErrorStatus(error) {
6
+ const e = error;
7
+ return e?.status ?? e?.statusCode ?? e?.response?.status ?? 0;
8
+ }
9
+ export function getErrorDetail(error) {
10
+ const data = getErrorData(error);
11
+ return typeof data?.detail === "string" ? data.detail : void 0;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapomodule/core",
3
- "version": "2.0.0-beta.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "Mapo core: API layer, auth, composables, middleware",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@mapomodule/store": "2.0.0-beta.4",
50
- "@mapomodule/utils": "2.0.0-beta.2"
50
+ "@mapomodule/utils": "2.0.0-beta.3"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "nuxt": ">=4.0.0",
@@ -57,10 +57,10 @@
57
57
  "@nuxt/kit": "4.4.2",
58
58
  "@nuxt/module-builder": "^1.0.2",
59
59
  "@nuxt/schema": "4.4.2",
60
- "nuxt": "^4.4.2",
60
+ "nuxt": "^4.5.1",
61
61
  "ofetch": "^1.5.1",
62
62
  "pinia": "^3.0.4",
63
- "vue": "^3.5.32",
64
- "vue-router": "^5.0.4"
63
+ "vue": "^3.5.40",
64
+ "vue-router": "^5.2.0"
65
65
  }
66
66
  }
package/src/index.ts CHANGED
@@ -2,3 +2,8 @@ export type * from "./module";
2
2
  export { MultipartPolicyEnum, CoreCookieEnum } from "./runtime/types";
3
3
  export { useCanAccessRoute } from "./runtime/auth/useCanAccessRoute";
4
4
  export { useMapoFetch } from "./runtime/utils/useMapoFetch";
5
+ export {
6
+ getErrorData,
7
+ getErrorStatus,
8
+ getErrorDetail,
9
+ } from "./runtime/utils/fetchError";
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Exercises the REAL mapo-core:fetch plugin hooks, not a hand-written copy of them.
4
+ // The hooks are built inside setup() via the global `$fetch.create`, so stub that and
5
+ // capture what the plugin actually passes.
6
+
7
+ vi.mock("nuxt/app", () => ({
8
+ defineNuxtPlugin: (p: unknown) => p,
9
+ navigateTo: vi.fn(),
10
+ useRoute: () => ({ fullPath: "/articoli" }),
11
+ useRuntimeConfig: () => ({
12
+ public: { mapoCore: { logoutUrl: "/logout", loginUrl: "/login" } },
13
+ }),
14
+ useRequestHeaders: () => ({ cookie: "__mapo_session=abc; sessionid=abc" }),
15
+ }));
16
+
17
+ const authStore = { reset: vi.fn() };
18
+ const snackStore = { show: vi.fn() };
19
+ vi.mock("@mapomodule/store/runtime/stores/auth", () => ({
20
+ useAuthStore: () => authStore,
21
+ }));
22
+ vi.mock("@mapomodule/store/runtime/stores/snack", () => ({
23
+ useSnackStore: () => snackStore,
24
+ }));
25
+
26
+ import plugin from "../../plugins/00.fetch";
27
+
28
+ type Hooks = {
29
+ onRequest: (ctx: { options: { headers?: HeadersInit } }) => void;
30
+ onResponse: (ctx: { response: { status: number }; request: string }) => void;
31
+ onResponseError?: undefined;
32
+ onRequestError: () => void;
33
+ };
34
+
35
+ function buildPlugin() {
36
+ let hooks!: Hooks;
37
+ const provided: Record<string, unknown> = {};
38
+ vi.stubGlobal("$fetch", {
39
+ create: (h: Hooks) => {
40
+ hooks = h;
41
+ return vi.fn();
42
+ },
43
+ });
44
+ const res = (
45
+ plugin as unknown as { setup: () => { provide: Record<string, unknown> } }
46
+ ).setup();
47
+ Object.assign(provided, res.provide);
48
+ return { hooks, provided };
49
+ }
50
+
51
+ describe("mapo-core:fetch — real plugin hooks", () => {
52
+ beforeEach(() => vi.clearAllMocks());
53
+
54
+ // ofetch runs onResponse for EVERY response and THEN onResponseError for >= 400.
55
+ // Decrementing in both drives the counter negative and wedges $mapoFetchLoading.
56
+ it("decrements the pending counter exactly once on an HTTP error", () => {
57
+ const { hooks, provided } = buildPlugin();
58
+ const loading = provided.mapoFetchLoading as { value: boolean };
59
+
60
+ hooks.onRequest({ options: {} } as never);
61
+ expect(loading.value).toBe(true);
62
+
63
+ // real ofetch order for an error response (verified against ofetch 1.5.1):
64
+ // onRequest -> onResponse(500) -> onResponseError(500)
65
+ hooks.onResponse({ response: { status: 500 }, request: "/api/x" });
66
+
67
+ expect(loading.value).toBe(false);
68
+
69
+ // A second request must still register — it would not if the counter went to -1.
70
+ hooks.onRequest({ options: {} } as never);
71
+ expect(loading.value).toBe(true);
72
+ });
73
+
74
+ it("does not swallow ofetch's FetchError by returning a rejection", () => {
75
+ const { hooks } = buildPlugin();
76
+ const returned = hooks.onResponse({
77
+ response: { status: 401 },
78
+ request: "/api/x",
79
+ });
80
+ expect(returned).toBeUndefined();
81
+ });
82
+
83
+ it("still resets auth on 401 and snacks on 403", () => {
84
+ const { hooks } = buildPlugin();
85
+
86
+ hooks.onResponse({ response: { status: 401 }, request: "/api/x" });
87
+ expect(authStore.reset).toHaveBeenCalled();
88
+
89
+ hooks.onResponse({ response: { status: 403 }, request: "/api/y" });
90
+ expect(snackStore.show).toHaveBeenCalledWith("Permission denied", "error");
91
+ });
92
+
93
+ // Was fired twice per 403 while both hooks called handle().
94
+ it("shows the 403 snackbar exactly once per response", () => {
95
+ const { hooks } = buildPlugin();
96
+ hooks.onResponse({ response: { status: 403 }, request: "/api/y" });
97
+ expect(snackStore.show).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ // SSR does not attach browser cookies. Without forwarding, every authenticated data
101
+ // fetch came back 401 and `handle()` wiped the auth store mid-render — an
102
+ // intermittent logout on hard refresh, depending on whether the 401 landed before
103
+ // the payload was serialized.
104
+ it("forwards the incoming Cookie header on server-side requests", () => {
105
+ const { hooks } = buildPlugin();
106
+ const options: { headers?: HeadersInit } = {};
107
+ hooks.onRequest({ options } as never);
108
+
109
+ const headers = options.headers as Headers;
110
+ expect(headers.get("cookie")).toBe("__mapo_session=abc; sessionid=abc");
111
+ });
112
+
113
+ it("lets an explicit per-call cookie win", () => {
114
+ const { hooks } = buildPlugin();
115
+ const options: { headers?: HeadersInit } = {
116
+ headers: { cookie: "sessionid=explicit" },
117
+ };
118
+ hooks.onRequest({ options } as never);
119
+
120
+ expect((options.headers as Headers).get("cookie")).toBe(
121
+ "sessionid=explicit",
122
+ );
123
+ });
124
+ });
@@ -73,7 +73,9 @@ describe("Mapo Fetch Plugin — Pending Counter", () => {
73
73
  mapoFetch.onRequest();
74
74
  expect(pending.value).toBe(1);
75
75
 
76
- // Simulate HTTP error (500): onResponseError is called instead of onResponse
76
+ // Simulate HTTP error (500). NOTE: ofetch calls onResponse for EVERY response
77
+ // (ofetch createFetch: onResponse, then onResponseError for status >= 400), so the
78
+ // real plugin must decrement in onResponse only. See the `real plugin` suite below.
77
79
  mapoFetch.onResponseError({
78
80
  response: { status: 500 },
79
81
  request: "/api/users",
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // --- nuxt/app is a virtual runtime module; stub the handful of helpers used. ---
4
+ const cookieRef = { value: "a-session-value" as string | null };
5
+ const runtimeConfig = { public: { mapoCore: {} as Record<string, unknown> } };
6
+
7
+ vi.mock("nuxt/app", () => ({
8
+ defineNuxtPlugin: (p: unknown) => p,
9
+ useRuntimeConfig: () => runtimeConfig,
10
+ useCookie: () => cookieRef,
11
+ useRequestHeaders: () => ({ cookie: "__mapo_session=a-session-value" }),
12
+ }));
13
+
14
+ vi.mock("pinia", () => ({ setActivePinia: vi.fn() }));
15
+
16
+ const authStore = { setUser: vi.fn(), reset: vi.fn() };
17
+ vi.mock("@mapomodule/store/runtime/stores/auth", () => ({
18
+ useAuthStore: () => authStore,
19
+ }));
20
+ vi.mock("@mapomodule/store/runtime/stores/sidebar", () => ({
21
+ useSidebarStore: () => ({ hydrateFromCookies: vi.fn() }),
22
+ }));
23
+
24
+ // Imported after the mocks (vitest hoists them).
25
+ import plugin from "../../plugins/01.init.server";
26
+
27
+ function run($mapoFetch: unknown) {
28
+ return (
29
+ plugin as unknown as {
30
+ setup: (app: Record<string, unknown>) => Promise<void>;
31
+ }
32
+ ).setup({ $pinia: {}, $mapoFetch });
33
+ }
34
+
35
+ describe("mapo-core:init (SSR session restore)", () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ cookieRef.value = "a-session-value";
39
+ runtimeConfig.public.mapoCore = {};
40
+ });
41
+
42
+ // The bug this guards: building `${protocol}//${host}${userInfoApi}` dropped
43
+ // `app.baseURL` (404 under /backoffice/) and aimed at the browser-facing host,
44
+ // which the SSR container cannot resolve — so every hard refresh logged the user out.
45
+ it("fetches the user with a RELATIVE url so app.baseURL applies", async () => {
46
+ const fetchMock = vi.fn(() => Promise.resolve({ username: "root" }));
47
+ await run(fetchMock);
48
+
49
+ expect(fetchMock).toHaveBeenCalledTimes(1);
50
+ const [url] = fetchMock.mock.calls[0] as unknown as [string];
51
+ expect(url).toBe("/api/profiles/me/");
52
+ expect(url).not.toMatch(/^https?:\/\//);
53
+ expect(authStore.setUser).toHaveBeenCalledWith({ username: "root" });
54
+ });
55
+
56
+ it("honours a configured userInfoApi, still relative", async () => {
57
+ runtimeConfig.public.mapoCore = { userInfoApi: "/api/whoami/" };
58
+ const fetchMock = vi.fn(() => Promise.resolve({ username: "root" }));
59
+ await run(fetchMock);
60
+
61
+ const [url] = fetchMock.mock.calls[0] as unknown as [string];
62
+ expect(url).toBe("/api/whoami/");
63
+ });
64
+
65
+ it("clears the session cookie when the backend rejects it (401)", async () => {
66
+ const fetchMock = vi.fn(() =>
67
+ Promise.reject(Object.assign(new Error("Unauthorized"), { status: 401 })),
68
+ );
69
+ await run(fetchMock);
70
+
71
+ expect(authStore.reset).toHaveBeenCalled();
72
+ expect(cookieRef.value).toBeNull();
73
+ });
74
+
75
+ // A transport failure must not log the user out: the cookie is HttpOnly, so
76
+ // clearing it here is irreversible for the client.
77
+ it("keeps the session cookie when the backend is unreachable", async () => {
78
+ const fetchMock = vi.fn(() =>
79
+ Promise.reject(Object.assign(new Error("Bad Gateway"), { status: 502 })),
80
+ );
81
+ await run(fetchMock);
82
+
83
+ expect(authStore.reset).toHaveBeenCalled();
84
+ expect(cookieRef.value).toBe("a-session-value");
85
+ });
86
+
87
+ it("does nothing when there is no session cookie", async () => {
88
+ cookieRef.value = null;
89
+ const fetchMock = vi.fn();
90
+ await run(fetchMock);
91
+
92
+ expect(fetchMock).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { ofetch } from "ofetch";
3
+ import {
4
+ getErrorData,
5
+ getErrorDetail,
6
+ getErrorStatus,
7
+ } from "../../utils/fetchError";
8
+
9
+ /** Minimal stand-in for the response the backend would return. */
10
+ function jsonResponse(status: number, body: unknown): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: { "content-type": "application/json" },
14
+ });
15
+ }
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ });
20
+
21
+ describe("fetchError helpers", () => {
22
+ it("reads the parsed body from FetchError.data", () => {
23
+ const err = {
24
+ status: 400,
25
+ data: { detail: "Invalid", title: ["required"] },
26
+ };
27
+ expect(getErrorData(err)).toEqual({
28
+ detail: "Invalid",
29
+ title: ["required"],
30
+ });
31
+ expect(getErrorDetail(err)).toBe("Invalid");
32
+ expect(getErrorStatus(err)).toBe(400);
33
+ });
34
+
35
+ it("falls back to response._data, which is where ofetch parks the body", () => {
36
+ const err = { response: { status: 404, _data: { detail: "Not found" } } };
37
+ expect(getErrorDetail(err)).toBe("Not found");
38
+ expect(getErrorStatus(err)).toBe(404);
39
+ });
40
+
41
+ it("returns undefined instead of throwing for network errors", () => {
42
+ expect(getErrorData(new Error("network down"))).toBeUndefined();
43
+ expect(getErrorDetail(undefined)).toBeUndefined();
44
+ expect(getErrorStatus(null)).toBe(0);
45
+ });
46
+
47
+ it("ignores a non-string detail so callers can use their own fallback", () => {
48
+ expect(
49
+ getErrorDetail({ data: { detail: { nested: true } } }),
50
+ ).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe("onResponseError hook contract", () => {
55
+ // Regression guard: the plugin used to `return Promise.reject(context.error)`.
56
+ // On an HTTP error status ofetch leaves `context.error` undefined, so that
57
+ // rejection replaced the FetchError with `undefined` and every caller lost
58
+ // both the status and the backend body.
59
+ it("keeps the FetchError intact when the hook only observes", async () => {
60
+ vi.stubGlobal("fetch", async () =>
61
+ jsonResponse(400, { detail: "Some nodes are invalid." }),
62
+ );
63
+
64
+ let seenError: unknown = "not-called";
65
+ const api = ofetch.create({
66
+ onResponseError(ctx) {
67
+ seenError = ctx.error;
68
+ },
69
+ });
70
+
71
+ const err = await api("/api/menus/1", { method: "PUT" }).catch((e) => e);
72
+
73
+ // ofetch does not populate context.error for HTTP error statuses…
74
+ expect(seenError).toBeUndefined();
75
+ // …but the caller still receives a fully-formed FetchError.
76
+ expect(err).toBeDefined();
77
+ expect(getErrorStatus(err)).toBe(400);
78
+ expect(getErrorDetail(err)).toBe("Some nodes are invalid.");
79
+ });
80
+
81
+ it("loses the error payload when the hook rejects with context.error", async () => {
82
+ vi.stubGlobal("fetch", async () =>
83
+ jsonResponse(400, { detail: "Some nodes are invalid." }),
84
+ );
85
+
86
+ const api = ofetch.create({
87
+ onResponseError(ctx) {
88
+ return Promise.reject(ctx.error);
89
+ },
90
+ });
91
+
92
+ const err = await api("/api/menus/1", { method: "PUT" }).catch((e) => e);
93
+
94
+ expect(err).toBeUndefined();
95
+ expect(getErrorDetail(err)).toBeUndefined();
96
+ });
97
+ });
@@ -2,6 +2,7 @@ import { ref, computed } from "vue";
2
2
  import {
3
3
  defineNuxtPlugin,
4
4
  navigateTo,
5
+ useRequestHeaders,
5
6
  useRoute,
6
7
  useRuntimeConfig,
7
8
  } from "nuxt/app";
@@ -59,19 +60,25 @@ export default defineNuxtPlugin({
59
60
  const pending = ref(0);
60
61
  const mapoFetchLoading = computed(() => pending.value > 0);
61
62
 
63
+ const forwardedCookie = import.meta.server
64
+ ? useRequestHeaders(["cookie"]).cookie
65
+ : undefined;
66
+
62
67
  const mapoFetch = $fetch.create({
63
- onRequest() {
68
+ onRequest({ options }) {
64
69
  pending.value++;
70
+ if (forwardedCookie) {
71
+ const headers = new Headers(
72
+ options.headers as HeadersInit | undefined,
73
+ );
74
+ if (!headers.has("cookie")) headers.set("cookie", forwardedCookie);
75
+ options.headers = headers;
76
+ }
65
77
  },
66
78
  onResponse({ response, request }) {
67
79
  pending.value--;
68
80
  handle(response.status, String(request));
69
81
  },
70
- onResponseError({ response, request, error }) {
71
- pending.value--;
72
- handle(response?.status ?? 0, String(request));
73
- return Promise.reject(error);
74
- },
75
82
  onRequestError() {
76
83
  // Decrement counter on network errors (no response received).
77
84
  pending.value--;
@@ -3,7 +3,6 @@ import {
3
3
  useRuntimeConfig,
4
4
  useCookie,
5
5
  useRequestHeaders,
6
- useRequestURL,
7
6
  } from "nuxt/app";
8
7
  import { setActivePinia } from "pinia";
9
8
  import type { Pinia } from "pinia";
@@ -22,7 +21,8 @@ import type { MapoCoreRuntimeConfig } from "../types";
22
21
  * - If the HttpOnly session cookie is present, calls the configured `userInfoApi`
23
22
  * (forwarding request cookies) to restore the authenticated user in the auth store.
24
23
  *
25
- * On session restore failure, it resets auth state and clears the session cookie.
24
+ * On session restore failure it resets auth state, and clears the session cookie only
25
+ * when the backend rejected the session (401/403) — never on a transport error.
26
26
  */
27
27
  export default defineNuxtPlugin({
28
28
  name: "mapo-core:init",
@@ -47,16 +47,20 @@ export default defineNuxtPlugin({
47
47
 
48
48
  try {
49
49
  const headers = useRequestHeaders(["cookie"]);
50
- const url = useRequestURL();
51
- const absolute = `${url.protocol}//${url.host}${userInfoApi}`;
52
50
  const user = await (
53
51
  nuxtApp.$mapoFetch as (url: string, opts: object) => Promise<unknown>
54
- )(absolute, { method: "GET", headers });
52
+ )(userInfoApi, { method: "GET", headers });
55
53
  authStore.setUser(user as MapoUser);
56
54
  } catch (e) {
57
55
  console.error(`[mapo] Cannot restore session from cookie:\n${e}`);
58
56
  authStore.reset();
59
- sessionCookie.value = null;
57
+ // Only drop the cookie when the backend actually rejected the session.
58
+ // A network blip or a 5xx must not log the user out: the cookie is HttpOnly,
59
+ // so clearing it here is irreversible for the client.
60
+ const status =
61
+ (e as { status?: number; response?: { status?: number } })?.status ??
62
+ (e as { response?: { status?: number } })?.response?.status;
63
+ if (status === 401 || status === 403) sessionCookie.value = null;
60
64
  }
61
65
  },
62
66
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Helpers to read the backend payload out of a failed `$mapoFetch` call.
3
+ *
4
+ * `ofetch` throws a `FetchError` that exposes the parsed response body as
5
+ * `error.data` (and `error.response._data`) — NOT as `error.response.data`.
6
+ * Reading the wrong path silently yields `undefined`, which turns every
7
+ * backend validation message into a generic "something went wrong".
8
+ * Use these helpers instead of destructuring the error by hand.
9
+ */
10
+
11
+ /** Minimal structural view of an ofetch `FetchError`. */
12
+ interface FetchErrorLike {
13
+ status?: number;
14
+ statusCode?: number;
15
+ data?: unknown;
16
+ response?: { status?: number; _data?: unknown };
17
+ }
18
+
19
+ /**
20
+ * Parsed response body of a failed request, or `undefined` when the request
21
+ * failed before a response was received (network error, abort…).
22
+ *
23
+ * @example
24
+ * try { await crud.create(model) }
25
+ * catch (err) {
26
+ * const data = getErrorData<{ title?: string[] }>(err)
27
+ * errors.value = data ?? {}
28
+ * }
29
+ */
30
+ export function getErrorData<T = Record<string, unknown>>(
31
+ error: unknown,
32
+ ): T | undefined {
33
+ const e = error as FetchErrorLike | null | undefined;
34
+ return (e?.data ?? e?.response?._data) as T | undefined;
35
+ }
36
+
37
+ /** HTTP status of a failed request, or `0` when no response was received. */
38
+ export function getErrorStatus(error: unknown): number {
39
+ const e = error as FetchErrorLike | null | undefined;
40
+ return e?.status ?? e?.statusCode ?? e?.response?.status ?? 0;
41
+ }
42
+
43
+ /**
44
+ * `detail` message returned by the backend (DRF convention), when present.
45
+ * Falls back to `undefined` so callers can substitute a translated default.
46
+ */
47
+ export function getErrorDetail(error: unknown): string | undefined {
48
+ const data = getErrorData<{ detail?: unknown }>(error);
49
+ return typeof data?.detail === "string" ? data.detail : undefined;
50
+ }