@mapomodule/core 1.0.0 → 2.0.0-beta.10

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 (96) hide show
  1. package/README.md +56 -0
  2. package/dist/index.d.mts +14 -0
  3. package/dist/index.d.ts +6 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.mjs +4 -0
  6. package/dist/module.d.mts +11 -0
  7. package/dist/module.d.ts +10 -0
  8. package/dist/module.d.ts.map +1 -0
  9. package/dist/module.json +9 -0
  10. package/dist/module.mjs +78 -0
  11. package/dist/runtime/__tests__/plugins/00.fetch.real.spec.d.ts +2 -0
  12. package/dist/runtime/__tests__/plugins/00.fetch.real.spec.d.ts.map +1 -0
  13. package/dist/runtime/__tests__/plugins/00.fetch.spec.d.ts +2 -0
  14. package/dist/runtime/__tests__/plugins/00.fetch.spec.d.ts.map +1 -0
  15. package/dist/runtime/__tests__/plugins/01.init.server.spec.d.ts +2 -0
  16. package/dist/runtime/__tests__/plugins/01.init.server.spec.d.ts.map +1 -0
  17. package/dist/runtime/__tests__/utils/fetchError.spec.d.ts +2 -0
  18. package/dist/runtime/__tests__/utils/fetchError.spec.d.ts.map +1 -0
  19. package/dist/runtime/api/crud.d.ts +23 -0
  20. package/dist/runtime/api/crud.d.ts.map +1 -0
  21. package/dist/runtime/api/crud.js +80 -0
  22. package/dist/runtime/api/multipart.d.ts +15 -0
  23. package/dist/runtime/api/multipart.d.ts.map +1 -0
  24. package/dist/runtime/api/multipart.js +31 -0
  25. package/dist/runtime/auth/useCanAccessRoute.d.ts +12 -0
  26. package/dist/runtime/auth/useCanAccessRoute.d.ts.map +1 -0
  27. package/dist/runtime/auth/useCanAccessRoute.js +28 -0
  28. package/dist/runtime/auth/useMapoAuth.d.ts +28 -0
  29. package/dist/runtime/auth/useMapoAuth.d.ts.map +1 -0
  30. package/dist/runtime/auth/useMapoAuth.js +38 -0
  31. package/dist/runtime/composables/useMapo.d.ts +48 -0
  32. package/dist/runtime/composables/useMapo.d.ts.map +1 -0
  33. package/dist/runtime/composables/useMapo.js +15 -0
  34. package/dist/runtime/middleware/auth.d.ts +10 -0
  35. package/dist/runtime/middleware/auth.d.ts.map +1 -0
  36. package/dist/runtime/middleware/auth.js +15 -0
  37. package/dist/runtime/middleware/permissions.d.ts +20 -0
  38. package/dist/runtime/middleware/permissions.d.ts.map +1 -0
  39. package/dist/runtime/middleware/permissions.js +27 -0
  40. package/dist/runtime/middleware/roles.d.ts +12 -0
  41. package/dist/runtime/middleware/roles.d.ts.map +1 -0
  42. package/dist/runtime/middleware/roles.js +13 -0
  43. package/dist/runtime/page-meta.nuxt.d.ts +23 -0
  44. package/dist/runtime/plugins/00.fetch.d.ts +21 -0
  45. package/dist/runtime/plugins/00.fetch.d.ts.map +1 -0
  46. package/dist/runtime/plugins/00.fetch.js +61 -0
  47. package/dist/runtime/plugins/01.init.server.d.ts +15 -0
  48. package/dist/runtime/plugins/01.init.server.d.ts.map +1 -0
  49. package/dist/runtime/plugins/01.init.server.js +35 -0
  50. package/dist/runtime/types.d.ts +75 -0
  51. package/dist/runtime/types.d.ts.map +1 -0
  52. package/dist/runtime/types.js +10 -0
  53. package/dist/runtime/types.nuxt.d.ts +18 -0
  54. package/dist/runtime/utils/fetchError.d.ts +29 -0
  55. package/dist/runtime/utils/fetchError.d.ts.map +1 -0
  56. package/dist/runtime/utils/fetchError.js +12 -0
  57. package/dist/runtime/utils/useMapoFetch.d.ts +30 -0
  58. package/dist/runtime/utils/useMapoFetch.d.ts.map +1 -0
  59. package/dist/runtime/utils/useMapoFetch.js +8 -0
  60. package/dist/types.d.mts +13 -0
  61. package/dist/types.d.ts +13 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/package.json +58 -15
  64. package/src/index.ts +9 -0
  65. package/src/module.ts +114 -0
  66. package/src/runtime/__tests__/plugins/00.fetch.real.spec.ts +124 -0
  67. package/src/runtime/__tests__/plugins/00.fetch.spec.ts +146 -0
  68. package/src/runtime/__tests__/plugins/01.init.server.spec.ts +94 -0
  69. package/src/runtime/__tests__/utils/fetchError.spec.ts +97 -0
  70. package/src/runtime/api/crud.ts +138 -0
  71. package/src/runtime/api/multipart.ts +78 -0
  72. package/src/runtime/auth/useCanAccessRoute.ts +48 -0
  73. package/src/runtime/auth/useMapoAuth.ts +91 -0
  74. package/src/runtime/composables/useMapo.ts +61 -0
  75. package/src/runtime/middleware/auth.ts +24 -0
  76. package/src/runtime/middleware/permissions.ts +52 -0
  77. package/src/runtime/middleware/roles.ts +23 -0
  78. package/src/runtime/page-meta.nuxt.d.ts +23 -0
  79. package/src/runtime/plugins/00.fetch.ts +92 -0
  80. package/src/runtime/plugins/01.init.server.ts +66 -0
  81. package/src/runtime/types.nuxt.d.ts +18 -0
  82. package/src/runtime/types.ts +102 -0
  83. package/src/runtime/utils/fetchError.ts +50 -0
  84. package/src/runtime/utils/useMapoFetch.ts +41 -0
  85. package/src/types.ts +13 -0
  86. package/CHANGELOG.md +0 -248
  87. package/api/crud.js +0 -141
  88. package/api/multipart.js +0 -41
  89. package/index.js +0 -134
  90. package/interceptor/auth.js +0 -26
  91. package/interceptor/index.js +0 -5
  92. package/middleware/auth.js +0 -10
  93. package/middleware/permissions.js +0 -36
  94. package/middleware/roles.js +0 -10
  95. package/module.js +0 -44
  96. package/templates/options.js +0 -1
package/src/module.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ defineNuxtModule,
4
+ addImports,
5
+ addPlugin,
6
+ addRouteMiddleware,
7
+ addTypeTemplate,
8
+ hasNuxtModule,
9
+ installModule,
10
+ createResolver,
11
+ } from "@nuxt/kit";
12
+ import type { NuxtModule } from "@nuxt/schema";
13
+ import { MAPO_DEFAULTS } from "./types";
14
+ import type { MapoOptions } from "./types";
15
+
16
+ export type { MapoOptions } from "./types";
17
+ export type {
18
+ ListParams,
19
+ PaginatedResponse,
20
+ ApiResponse,
21
+ CrudConfig,
22
+ CrudOptions,
23
+ CrudRepository,
24
+ MultipartPolicy,
25
+ Credentials,
26
+ AuthToken,
27
+ } from "./runtime/types";
28
+ export { MultipartPolicyEnum, CoreCookieEnum } from "./runtime/types";
29
+ export type { MapoFacade, MapoApiLayer } from "./runtime/composables/useMapo";
30
+ export type { RoutePermissions } from "./runtime/auth/useCanAccessRoute";
31
+
32
+ export default defineNuxtModule<MapoOptions>({
33
+ meta: {
34
+ name: "@mapomodule/core",
35
+ configKey: "mapoCore",
36
+ },
37
+
38
+ defaults: MAPO_DEFAULTS,
39
+
40
+ async setup(options, nuxt) {
41
+ const resolver = createResolver(import.meta.url);
42
+
43
+ // In consuming apps this runtime lives inside node_modules, where Nuxt
44
+ // skips auto-import injection by default (workspace apps are unaffected:
45
+ // pnpm symlinks resolve outside node_modules). Opt back in and transpile
46
+ // the runtime for the SSR build.
47
+ nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
48
+ nuxt.options.imports.transform ??= {};
49
+ (nuxt.options.imports.transform.include ??= []).push(
50
+ /@mapomodule[\\/]core[\\/]/,
51
+ );
52
+
53
+ addTypeTemplate({
54
+ filename: "types/mapo-core-page-meta.d.ts",
55
+ getContents: () =>
56
+ readFileSync(
57
+ resolver.resolve("./runtime/page-meta.nuxt.d.ts"),
58
+ "utf-8",
59
+ ),
60
+ });
61
+
62
+ if (!hasNuxtModule("@mapomodule/store")) {
63
+ // Resolve from core's own node_modules: with pnpm's isolated layout the
64
+ // consuming app cannot resolve the bare "@mapomodule/store" specifier.
65
+ await installModule(await resolver.resolvePath("@mapomodule/store"));
66
+ }
67
+
68
+ // `defaults` guarantees these are set at runtime, but MapoOptions keeps
69
+ // them optional — spread the defaults so the assignment is fully defined.
70
+ nuxt.options.runtimeConfig.public.mapoCore = {
71
+ ...MAPO_DEFAULTS,
72
+ ...options,
73
+ };
74
+
75
+ addPlugin({ src: resolver.resolve("./runtime/plugins/00.fetch") });
76
+ addPlugin({
77
+ src: resolver.resolve("./runtime/plugins/01.init.server"),
78
+ mode: "server",
79
+ });
80
+
81
+ addImports([
82
+ { name: "useCrud", from: resolver.resolve("./runtime/api/crud") },
83
+ {
84
+ name: "useMapoAuth",
85
+ from: resolver.resolve("./runtime/auth/useMapoAuth"),
86
+ },
87
+ {
88
+ name: "useMapo",
89
+ from: resolver.resolve("./runtime/composables/useMapo"),
90
+ },
91
+ {
92
+ name: "useCanAccessRoute",
93
+ from: resolver.resolve("./runtime/auth/useCanAccessRoute"),
94
+ },
95
+ {
96
+ name: "useMapoFetch",
97
+ from: resolver.resolve("./runtime/utils/useMapoFetch"),
98
+ },
99
+ ]);
100
+
101
+ addRouteMiddleware({
102
+ name: "auth",
103
+ path: resolver.resolve("./runtime/middleware/auth"),
104
+ });
105
+ addRouteMiddleware({
106
+ name: "permissions",
107
+ path: resolver.resolve("./runtime/middleware/permissions"),
108
+ });
109
+ addRouteMiddleware({
110
+ name: "roles",
111
+ path: resolver.resolve("./runtime/middleware/roles"),
112
+ });
113
+ },
114
+ }) satisfies NuxtModule<MapoOptions>;
@@ -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
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { ref } from "vue";
3
+
4
+ // Mock setupPlugin function behavior for testing
5
+ function setupFetchPlugin() {
6
+ let redirectInFlight = false;
7
+ const pending = ref(0);
8
+
9
+ const handle = (status: number, requestUrl: string, logoutUrl: string) => {
10
+ if (
11
+ status === 401 &&
12
+ !requestUrl.includes(logoutUrl) &&
13
+ !redirectInFlight
14
+ ) {
15
+ redirectInFlight = true;
16
+ // In real plugin, would redirect; here we just track the intent
17
+ }
18
+ };
19
+
20
+ const mapoFetch = {
21
+ onRequest: () => {
22
+ pending.value++;
23
+ },
24
+ onResponse: (ctx: { response: { status: number }; request: string }) => {
25
+ pending.value--;
26
+ handle(ctx.response.status, ctx.request, "/logout");
27
+ },
28
+ onResponseError: (ctx: {
29
+ response?: { status: number };
30
+ request: string;
31
+ }) => {
32
+ pending.value--;
33
+ handle(ctx.response?.status ?? 0, ctx.request, "/logout");
34
+ },
35
+ onRequestError: () => {
36
+ pending.value--;
37
+ },
38
+ };
39
+
40
+ return { pending, mapoFetch };
41
+ }
42
+
43
+ describe("Mapo Fetch Plugin — Pending Counter", () => {
44
+ let fixture: ReturnType<typeof setupFetchPlugin>;
45
+
46
+ beforeEach(() => {
47
+ fixture = setupFetchPlugin();
48
+ });
49
+
50
+ it("should increment pending on request and decrement on successful response", () => {
51
+ const { pending, mapoFetch } = fixture;
52
+
53
+ expect(pending.value).toBe(0);
54
+
55
+ // Simulate request
56
+ mapoFetch.onRequest();
57
+ expect(pending.value).toBe(1);
58
+
59
+ // Simulate successful response (200)
60
+ mapoFetch.onResponse({
61
+ response: { status: 200 },
62
+ request: "/api/users",
63
+ });
64
+ expect(pending.value).toBe(0);
65
+ });
66
+
67
+ it("should decrement pending exactly once on HTTP error (not double-decrement)", () => {
68
+ const { pending, mapoFetch } = fixture;
69
+
70
+ expect(pending.value).toBe(0);
71
+
72
+ // Simulate request
73
+ mapoFetch.onRequest();
74
+ expect(pending.value).toBe(1);
75
+
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.
79
+ mapoFetch.onResponseError({
80
+ response: { status: 500 },
81
+ request: "/api/users",
82
+ });
83
+ expect(pending.value).toBe(0);
84
+ // Should NOT be -1 (which would indicate double-decrement)
85
+ });
86
+
87
+ it("should decrement pending on network error (onRequestError)", () => {
88
+ const { pending, mapoFetch } = fixture;
89
+
90
+ expect(pending.value).toBe(0);
91
+
92
+ // Simulate request
93
+ mapoFetch.onRequest();
94
+ expect(pending.value).toBe(1);
95
+
96
+ // Simulate network error: onRequestError is called
97
+ mapoFetch.onRequestError();
98
+ expect(pending.value).toBe(0);
99
+ });
100
+
101
+ it("should handle multiple concurrent requests correctly", () => {
102
+ const { pending, mapoFetch } = fixture;
103
+
104
+ // Three concurrent requests
105
+ mapoFetch.onRequest(); // Request 1
106
+ mapoFetch.onRequest(); // Request 2
107
+ mapoFetch.onRequest(); // Request 3
108
+ expect(pending.value).toBe(3);
109
+
110
+ // First completes successfully
111
+ mapoFetch.onResponse({
112
+ response: { status: 200 },
113
+ request: "/api/users",
114
+ });
115
+ expect(pending.value).toBe(2);
116
+
117
+ // Second fails with HTTP error
118
+ mapoFetch.onResponseError({
119
+ response: { status: 500 },
120
+ request: "/api/posts",
121
+ });
122
+ expect(pending.value).toBe(1);
123
+
124
+ // Third fails with network error
125
+ mapoFetch.onRequestError();
126
+ expect(pending.value).toBe(0);
127
+ });
128
+
129
+ it("should never go negative", () => {
130
+ const { pending, mapoFetch } = fixture;
131
+
132
+ // Ensure pending never goes negative even with edge cases
133
+ mapoFetch.onRequest();
134
+ expect(pending.value).toBe(1);
135
+
136
+ // Simulate response
137
+ mapoFetch.onResponse({
138
+ response: { status: 200 },
139
+ request: "/api/users",
140
+ });
141
+ expect(pending.value).toBe(0);
142
+
143
+ // Should not go negative
144
+ expect(pending.value).toBeGreaterThanOrEqual(0);
145
+ });
146
+ });
@@ -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
+ });