acinguiux-dnr-utils 0.0.1

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 (39) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +43 -0
  3. package/package.json +57 -0
  4. package/src/application-insights/index.ts +37 -0
  5. package/src/authentication/auth.ts +59 -0
  6. package/src/authentication/callbacks.test.ts +226 -0
  7. package/src/authentication/callbacks.ts +68 -0
  8. package/src/authentication/client-auth-guard.test.ts +116 -0
  9. package/src/authentication/client-auth-guard.ts +29 -0
  10. package/src/authentication/helpers.test.ts +41 -0
  11. package/src/authentication/helpers.ts +14 -0
  12. package/src/authentication/index.ts +8 -0
  13. package/src/authentication/middleware.ts +17 -0
  14. package/src/authentication/provider.test.ts +168 -0
  15. package/src/authentication/provider.ts +56 -0
  16. package/src/authentication/services/permissions.test.ts +102 -0
  17. package/src/authentication/services/permissions.ts +52 -0
  18. package/src/formatters/index.ts +1 -0
  19. package/src/formatters/number.test.ts +49 -0
  20. package/src/formatters/number.ts +25 -0
  21. package/src/instrumentation/browser.test.ts +118 -0
  22. package/src/instrumentation/browser.ts +24 -0
  23. package/src/instrumentation/node.test.ts +63 -0
  24. package/src/instrumentation/node.ts +81 -0
  25. package/src/loggers/auth-fetch.test.ts +383 -0
  26. package/src/loggers/auth-fetch.ts +79 -0
  27. package/src/loggers/index.ts +1 -0
  28. package/src/mappers/index.ts +1 -0
  29. package/src/mappers/name.test.ts +45 -0
  30. package/src/mappers/name.ts +16 -0
  31. package/src/ms-graph/README.md +47 -0
  32. package/src/ms-graph/get-graph-token.test.ts +116 -0
  33. package/src/ms-graph/get-graph-token.ts +41 -0
  34. package/src/ms-graph/index.ts +1 -0
  35. package/src/ms-graph/user-photo.test.ts +108 -0
  36. package/src/ms-graph/user-photo.ts +45 -0
  37. package/tsconfig.json +12 -0
  38. package/types.d.ts +26 -0
  39. package/vitest.config.js +20 -0
@@ -0,0 +1,24 @@
1
+ import { faro, getWebInstrumentations, initializeFaro } from "@grafana/faro-web-sdk";
2
+ import type { Session } from "next-auth";
3
+
4
+ export const instrument = (productName: string, user: Session["user"]) => {
5
+ if (faro.api) return null;
6
+ if (!process.env.NEXT_PUBLIC_FARO_URL) return null;
7
+
8
+ initializeFaro({
9
+ url: `${process.env.NEXT_PUBLIC_FARO_URL}/collect`,
10
+ app: {
11
+ name: `${productName}-browser`,
12
+ namespace: `geneva-${productName}`,
13
+ },
14
+ user: {
15
+ email: user?.email || "",
16
+ fullName: user?.name || "",
17
+ },
18
+ pageTracking: {
19
+ generatePageId: (url) => url.pathname,
20
+ },
21
+ trackGeolocation: true,
22
+ instrumentations: [...getWebInstrumentations()],
23
+ });
24
+ };
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock only the heavy external dependencies that prevent module loading
4
+ vi.mock("@opentelemetry/winston-transport", () => ({
5
+ OpenTelemetryTransportV3: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("winston", () => ({
9
+ createLogger: vi.fn(() => ({
10
+ info: vi.fn(),
11
+ error: vi.fn(),
12
+ warn: vi.fn(),
13
+ })),
14
+ }));
15
+
16
+ describe("node instrumentation", () => {
17
+ const originalEnv = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
18
+
19
+ beforeEach(() => {
20
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
21
+ });
22
+
23
+ afterEach(() => {
24
+ if (originalEnv) {
25
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = originalEnv;
26
+ }
27
+ });
28
+
29
+ it("should export an instrument function", async () => {
30
+ const { instrument } = await import("./node");
31
+ expect(typeof instrument).toBe("function");
32
+ });
33
+
34
+ it("should export a logger", async () => {
35
+ const { logger } = await import("./node");
36
+ expect(logger).toBeDefined();
37
+ expect(typeof logger.info).toBe("function");
38
+ expect(typeof logger.error).toBe("function");
39
+ });
40
+
41
+ it("should return early when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => {
42
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
43
+
44
+ const { instrument } = await import("./node");
45
+ const result = instrument("test-product");
46
+
47
+ expect(result).toBeUndefined();
48
+ });
49
+
50
+ it("should accept product name parameter without throwing", async () => {
51
+ const { instrument } = await import("./node");
52
+ expect(() => instrument("service-name")).not.toThrow();
53
+ });
54
+
55
+ it("should initialize SDK when OTEL_EXPORTER_OTLP_ENDPOINT is set", async () => {
56
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.example.com";
57
+
58
+ const { instrument } = await import("./node");
59
+
60
+ // Should not throw when endpoint is configured
61
+ expect(() => instrument("test-service")).not.toThrow();
62
+ });
63
+ });
@@ -0,0 +1,81 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: supress noExplicitAny */
2
+
3
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
4
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
5
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
6
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
7
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
8
+ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
9
+ import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
10
+ import { resourceFromAttributes } from "@opentelemetry/resources";
11
+ import { api, logs, metrics, NodeSDK, node as traces } from "@opentelemetry/sdk-node";
12
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
13
+ import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
14
+ import { createLogger } from "winston";
15
+
16
+ export const logger = createLogger({
17
+ transports: [new OpenTelemetryTransportV3()],
18
+ });
19
+
20
+ class DisableExtractContextPropagator implements api.TextMapPropagator {
21
+ constructor(private readonly propagator: W3CTraceContextPropagator) {}
22
+ inject = (context: api.Context, carrier: any, setter = api.defaultTextMapSetter) =>
23
+ this.propagator.inject(context, carrier, setter);
24
+ extract = (context: api.Context, _carrier: any, _getter = api.defaultTextMapGetter) => context;
25
+ fields = () => this.propagator.fields();
26
+ }
27
+
28
+ export const instrument = (productName: string) => {
29
+ if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
30
+
31
+ api.propagation.setGlobalPropagator(
32
+ new DisableExtractContextPropagator(new W3CTraceContextPropagator()),
33
+ );
34
+
35
+ const sdk = new NodeSDK({
36
+ resource: resourceFromAttributes({
37
+ [ATTR_SERVICE_NAME]: `${productName}-ui`,
38
+ }),
39
+ metricReader: new metrics.PeriodicExportingMetricReader({
40
+ exporter: new OTLPMetricExporter({
41
+ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/metrics`,
42
+ }),
43
+ exportIntervalMillis: 10000,
44
+ }),
45
+ spanProcessors: [
46
+ new traces.BatchSpanProcessor(
47
+ new OTLPTraceExporter({
48
+ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
49
+ }),
50
+ ),
51
+ ],
52
+ logRecordProcessors: [
53
+ new logs.BatchLogRecordProcessor(
54
+ new OTLPLogExporter({
55
+ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/logs`,
56
+ }),
57
+ ),
58
+ ],
59
+ instrumentations: [
60
+ new HttpInstrumentation({
61
+ ignoreIncomingRequestHook: (request) => {
62
+ const ignoreUrls = ["/_next/static", "/_next/image", "/favicon.ico", "/api/auth"];
63
+
64
+ return ignoreUrls.some((url) => request.url?.includes(url));
65
+ },
66
+ }),
67
+ new UndiciInstrumentation({
68
+ ignoreRequestHook: (request) => {
69
+ const ignoreUrls = ["/v1/traces"];
70
+
71
+ return ignoreUrls.some((url) => request.path?.includes(url));
72
+ },
73
+ }),
74
+ new WinstonInstrumentation({
75
+ disableLogSending: true,
76
+ }),
77
+ ],
78
+ });
79
+
80
+ sdk.start();
81
+ };
@@ -0,0 +1,383 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import * as auth_module from "../authentication";
3
+ import * as logger_module from "../instrumentation/node";
4
+ import { authFetch } from "./auth-fetch";
5
+
6
+ vi.mock("../authentication", () => ({
7
+ auth: vi.fn(() => ({
8
+ access_token: "test-token",
9
+ user: { name: "Test User", email: "test@example.com" },
10
+ })),
11
+ }));
12
+
13
+ vi.mock("../instrumentation/node", () => ({
14
+ logger: {
15
+ error: vi.fn(),
16
+ info: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ describe("authFetch", () => {
21
+ const domain = "https://api.test.com";
22
+ const path = "test/path";
23
+ const queryParams = "key=value";
24
+
25
+ beforeEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ const makeJsonResponse = (
30
+ data: unknown,
31
+ {
32
+ ok = true,
33
+ status = 200,
34
+ statusText = "OK",
35
+ contentType = "application/json",
36
+ }: {
37
+ ok?: boolean;
38
+ status?: number;
39
+ statusText?: string;
40
+ contentType?: string;
41
+ } = {},
42
+ ) => {
43
+ const response: any = {
44
+ ok,
45
+ json: () => Promise.resolve(data),
46
+ headers: new Headers({ "content-type": contentType }),
47
+ status,
48
+ statusText,
49
+ };
50
+
51
+ if (!ok) {
52
+ response.clone = () => ({
53
+ json: () => Promise.resolve(data),
54
+ status,
55
+ statusText,
56
+ });
57
+ }
58
+
59
+ return response;
60
+ };
61
+
62
+ const makeTextResponse = (
63
+ text: string,
64
+ {
65
+ ok = true,
66
+ status = 200,
67
+ statusText = "OK",
68
+ contentType = "text/plain",
69
+ }: {
70
+ ok?: boolean;
71
+ status?: number;
72
+ statusText?: string;
73
+ contentType?: string;
74
+ } = {},
75
+ ) => {
76
+ const response: any = {
77
+ ok,
78
+ text: () => Promise.resolve(text),
79
+ headers: new Headers({ "content-type": contentType }),
80
+ status,
81
+ statusText,
82
+ };
83
+
84
+ if (!ok) {
85
+ response.clone = () => ({
86
+ text: () => Promise.resolve(text),
87
+ status,
88
+ statusText,
89
+ });
90
+ }
91
+
92
+ return response;
93
+ };
94
+
95
+ const mockFetchResolved = (response: any) => {
96
+ global.fetch = vi.fn().mockResolvedValue(response);
97
+ };
98
+
99
+ it("uses default requestOptions when no additional options are provided", async () => {
100
+ mockFetchResolved(makeJsonResponse({}));
101
+
102
+ await authFetch(`${domain}/${path}?${queryParams}`, {});
103
+
104
+ expect(global.fetch).toHaveBeenCalledWith(
105
+ `${domain}/${path}?${queryParams}`,
106
+ expect.objectContaining({
107
+ method: "GET",
108
+ headers: expect.any(Headers),
109
+ }),
110
+ );
111
+ });
112
+
113
+ it("merges additional options into requestOptions", async () => {
114
+ mockFetchResolved(makeJsonResponse({}));
115
+
116
+ const additionalOptions = {
117
+ method: "POST",
118
+ body: JSON.stringify({ data: "test" }),
119
+ };
120
+
121
+ await authFetch(`${domain}/${path}?${queryParams}`, additionalOptions);
122
+
123
+ expect(global.fetch).toHaveBeenCalledWith(
124
+ `${domain}/${path}?${queryParams}`,
125
+ expect.objectContaining({
126
+ method: "POST",
127
+ headers: expect.any(Headers),
128
+ body: '{"data":"test"}',
129
+ }),
130
+ );
131
+ });
132
+
133
+ it("includes the Authorization header with the token", async () => {
134
+ mockFetchResolved(makeJsonResponse({}));
135
+
136
+ await authFetch(`${domain}/${path}`, {});
137
+
138
+ const callArgs = (global.fetch as any).mock.calls[0];
139
+ const headers = callArgs[1].headers as Headers;
140
+
141
+ expect(headers.get("Authorization")).toBe("Bearer test-token");
142
+ });
143
+
144
+ it("logs success when response is ok with JSON content", async () => {
145
+ mockFetchResolved(makeJsonResponse({ data: "success" }));
146
+
147
+ await authFetch(`${domain}/${path}?${queryParams}`);
148
+
149
+ const logger = logger_module.logger;
150
+ expect(logger.info).toHaveBeenCalledWith(
151
+ expect.stringContaining("API request succeeded"),
152
+ expect.objectContaining({
153
+ path: `/${path}`,
154
+ status: 200,
155
+ }),
156
+ );
157
+ });
158
+
159
+ it("throws error when response is not ok with JSON error body", async () => {
160
+ mockFetchResolved(
161
+ makeJsonResponse(
162
+ {
163
+ context: {
164
+ errors: [{ msg: "Invalid request" }, { msg: "Missing field" }],
165
+ },
166
+ },
167
+ { ok: false, status: 400, statusText: "Bad Request" },
168
+ ),
169
+ );
170
+
171
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Invalid request | Missing field");
172
+
173
+ const logger = logger_module.logger;
174
+ expect(logger.error).toHaveBeenCalledWith(
175
+ expect.stringContaining("API request failed"),
176
+ expect.any(Object),
177
+ );
178
+ });
179
+
180
+ it("throws error when response is not ok with text error body", async () => {
181
+ mockFetchResolved(
182
+ makeTextResponse("Error message", {
183
+ ok: false,
184
+ status: 500,
185
+ statusText: "Internal Server Error",
186
+ }),
187
+ );
188
+
189
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Error message");
190
+ });
191
+
192
+ it("logs error with session data when request fails", async () => {
193
+ mockFetchResolved(makeJsonResponse({}, { ok: false, status: 401, statusText: "Unauthorized" }));
194
+
195
+ try {
196
+ await authFetch(`${domain}/${path}`);
197
+ } catch {
198
+ // Expected error
199
+ }
200
+
201
+ const logger = logger_module.logger;
202
+ expect(logger.error).toHaveBeenCalledWith(
203
+ expect.any(String),
204
+ expect.objectContaining({
205
+ status: 401,
206
+ user: expect.any(Object),
207
+ }),
208
+ );
209
+ });
210
+
211
+ it("logs error with anonymous user when session has no user", async () => {
212
+ vi.mocked(auth_module.auth).mockResolvedValue({} as any);
213
+
214
+ mockFetchResolved(makeJsonResponse({}, { ok: false, status: 401, statusText: "Unauthorized" }));
215
+
216
+ try {
217
+ await authFetch(`${domain}/${path}`);
218
+ } catch {
219
+ // Expected error
220
+ }
221
+
222
+ const logger = logger_module.logger;
223
+ expect(logger.error).toHaveBeenCalledWith(
224
+ expect.any(String),
225
+ expect.objectContaining({
226
+ user: expect.objectContaining({
227
+ name: "Anonymous",
228
+ email: "anonymous@shell.com",
229
+ }),
230
+ }),
231
+ );
232
+ });
233
+
234
+ it("returns response object on successful fetch", async () => {
235
+ const mockResponse = makeJsonResponse({ data: "test" });
236
+
237
+ mockFetchResolved(mockResponse);
238
+
239
+ const response = await authFetch(`${domain}/${path}`);
240
+
241
+ expect(response).toBe(mockResponse);
242
+ });
243
+
244
+ it("sets Content-Type header to application/json", async () => {
245
+ mockFetchResolved(makeJsonResponse({}));
246
+
247
+ await authFetch(`${domain}/${path}`, {});
248
+
249
+ const callArgs = (global.fetch as any).mock.calls[0];
250
+ const headers = callArgs[1].headers as Headers;
251
+
252
+ expect(headers.get("Content-Type")).toBe("application/json");
253
+ });
254
+
255
+ it("does not set Authorization header when session has no access_token", async () => {
256
+ vi.mocked(auth_module.auth).mockResolvedValueOnce({
257
+ user: { name: "Test User", email: "test@example.com" },
258
+ } as any);
259
+
260
+ mockFetchResolved(makeJsonResponse({}));
261
+
262
+ await authFetch(`${domain}/${path}`, {});
263
+
264
+ const callArgs = (global.fetch as any).mock.calls[0];
265
+ const headers = callArgs[1].headers as Headers;
266
+
267
+ expect(headers.get("Authorization")).toBeNull();
268
+ });
269
+
270
+ it("handles error response with no error context", async () => {
271
+ mockFetchResolved(
272
+ makeJsonResponse(
273
+ { someOtherField: "value" },
274
+ { ok: false, status: 400, statusText: "Bad Request" },
275
+ ),
276
+ );
277
+
278
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
279
+ "Failed to read error response text.",
280
+ );
281
+ });
282
+
283
+ it("handles error response with empty error array", async () => {
284
+ mockFetchResolved(
285
+ makeJsonResponse(
286
+ { context: { errors: [] } },
287
+ { ok: false, status: 400, statusText: "Bad Request" },
288
+ ),
289
+ );
290
+
291
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
292
+ "Failed to read error response text.",
293
+ );
294
+ });
295
+
296
+ it("handles error response with error messages from error array", async () => {
297
+ mockFetchResolved(
298
+ makeJsonResponse(
299
+ {
300
+ context: {
301
+ errors: [{ msg: "Error 1" }, { msg: "Error 2" }, { other: "field" }],
302
+ },
303
+ },
304
+ { ok: false, status: 400, statusText: "Bad Request" },
305
+ ),
306
+ );
307
+
308
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(" | Error 1 | Error 2");
309
+ });
310
+
311
+ it("handles error response with non-JSON content type", async () => {
312
+ mockFetchResolved(
313
+ makeTextResponse("Text error response", {
314
+ ok: false,
315
+ status: 400,
316
+ statusText: "Bad Request",
317
+ }),
318
+ );
319
+
320
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow("Text error response");
321
+ });
322
+
323
+ it("handles error response with empty text response", async () => {
324
+ mockFetchResolved(
325
+ makeTextResponse("", {
326
+ ok: false,
327
+ status: 400,
328
+ statusText: "Bad Request",
329
+ }),
330
+ );
331
+
332
+ await expect(authFetch(`${domain}/${path}`)).rejects.toThrow(
333
+ "Failed to read error response text.",
334
+ );
335
+ });
336
+
337
+ it("returns response when throwOnError is false and response is not ok", async () => {
338
+ const mockResponse = makeJsonResponse(
339
+ {
340
+ context: {
341
+ errors: [{ msg: "Error message" }],
342
+ },
343
+ },
344
+ { ok: false, status: 400, statusText: "Bad Request" },
345
+ );
346
+
347
+ mockFetchResolved(mockResponse);
348
+
349
+ const response = await authFetch(`${domain}/${path}`, {}, false);
350
+
351
+ expect(response).toBe(mockResponse);
352
+ expect(response.ok).toBe(false);
353
+ // Verify the response body is still readable via clone
354
+ const clonedBody = await response.clone().json();
355
+ expect(clonedBody).toEqual({ context: { errors: [{ msg: "Error message" }] } });
356
+ });
357
+
358
+ it("logs error but does not throw when throwOnError is false", async () => {
359
+ const mockResponse = makeJsonResponse(
360
+ {
361
+ context: {
362
+ errors: [{ msg: "Test error" }],
363
+ },
364
+ },
365
+ { ok: false, status: 400, statusText: "Bad Request" },
366
+ );
367
+
368
+ mockFetchResolved(mockResponse);
369
+
370
+ const response = await authFetch(`${domain}/${path}`, {}, false);
371
+
372
+ expect(response.ok).toBe(false);
373
+
374
+ const logger = logger_module.logger;
375
+ expect(logger.error).toHaveBeenCalledWith(
376
+ expect.stringContaining("API request failed"),
377
+ expect.any(Object),
378
+ );
379
+ // Verify the response body is still readable via clone
380
+ const clonedBody = await response.clone().json();
381
+ expect(clonedBody).toEqual({ context: { errors: [{ msg: "Test error" }] } });
382
+ });
383
+ });
@@ -0,0 +1,79 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: Ignore any for the file */
2
+
3
+ declare type RequestInit = any;
4
+
5
+ import { auth } from "../authentication";
6
+ import { logger } from "../instrumentation/node";
7
+
8
+ export const authFetch = async (
9
+ url: string,
10
+ options: RequestInit = {},
11
+ throwOnError: boolean = true,
12
+ ) => {
13
+ const { pathname, searchParams } = new URL(url);
14
+ const humanReadablePath = pathname.replaceAll("/", " > ");
15
+ const session = await auth();
16
+
17
+ const headers = new Headers(options.headers);
18
+ headers.set("Content-Type", "application/json");
19
+ if (session?.access_token) {
20
+ headers.set("Authorization", `Bearer ${session.access_token}`);
21
+ }
22
+
23
+ const requestOptions: RequestInit = {
24
+ method: "GET",
25
+ ...options,
26
+ headers: headers,
27
+ };
28
+
29
+ const response = await fetch(url, requestOptions);
30
+ const isJson = response.headers.get("content-type")?.includes("application/json");
31
+
32
+ if (!response.ok) {
33
+ const responseClone = response.clone();
34
+ let errorText: string = "Failed to read error response text.";
35
+
36
+ if (isJson) {
37
+ const data = await responseClone.json();
38
+ const errors = data?.context?.errors;
39
+
40
+ if (Array.isArray(errors)) {
41
+ const messages = errors.reduce(
42
+ (acc: string, err: any) => (err.msg ? `${acc} | ${err.msg}` : acc),
43
+ "",
44
+ );
45
+ errorText = messages === "" ? errorText : messages;
46
+ }
47
+ } else {
48
+ const data = await responseClone.text();
49
+ errorText = data || errorText;
50
+ }
51
+
52
+ logger.error(`API request failed for (${humanReadablePath})`, {
53
+ path: pathname,
54
+ params: searchParams.toString(),
55
+ status: responseClone.status,
56
+ statusText: responseClone.statusText,
57
+ detail: errorText,
58
+ user: {
59
+ name: session?.user?.name || "Anonymous",
60
+ email: session?.user?.email || "anonymous@shell.com",
61
+ },
62
+ });
63
+
64
+ if (throwOnError) {
65
+ throw new Error(errorText);
66
+ } else {
67
+ return response;
68
+ }
69
+ }
70
+
71
+ logger.info(`API request succeeded for ${humanReadablePath}`, {
72
+ path: pathname,
73
+ params: searchParams.toString(),
74
+ status: response.status,
75
+ statusText: response.statusText,
76
+ });
77
+
78
+ return response;
79
+ };
@@ -0,0 +1 @@
1
+ export { authFetch } from "./auth-fetch";
@@ -0,0 +1 @@
1
+ export * from "./name";
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mapNameInitials } from "./name";
4
+
5
+ describe("mapNameInitials", () => {
6
+ it("should return initials for a full name", () => {
7
+ const result = mapNameInitials("John Doe");
8
+ expect(result).toBe("JD");
9
+ });
10
+
11
+ it("should return initials for a single name", () => {
12
+ const result = mapNameInitials("Cher");
13
+ expect(result).toBe("C");
14
+ });
15
+
16
+ it("should return undefined for an empty string", () => {
17
+ const result = mapNameInitials("");
18
+ expect(result).toBeUndefined();
19
+ });
20
+
21
+ it("should return undefined for null input", () => {
22
+ const result = mapNameInitials(null);
23
+ expect(result).toBeUndefined();
24
+ });
25
+
26
+ it("should handle names with extra spaces", () => {
27
+ const result = mapNameInitials(" Jane Austen ");
28
+ expect(result).toBe("JA");
29
+ });
30
+
31
+ it("should handle names with multiple spaces between words", () => {
32
+ const result = mapNameInitials("Mary Shelley");
33
+ expect(result).toBe("MS");
34
+ });
35
+
36
+ it("should handle names with special characters", () => {
37
+ const result = mapNameInitials("O'Connor");
38
+ expect(result).toBe("O");
39
+ });
40
+
41
+ it("should return two characters for names with more than two words", () => {
42
+ const result = mapNameInitials("Mike J Smith");
43
+ expect(result).toBe("MS");
44
+ });
45
+ });
@@ -0,0 +1,16 @@
1
+ export const mapNameInitials = (name?: string | null) => {
2
+ if (!name) {
3
+ return undefined;
4
+ }
5
+
6
+ const names = name.trim().split(" ");
7
+
8
+ const abbreviation = names
9
+ // take the first and last names
10
+ .filter((_name, i) => i === 0 || i === names.length - 1)
11
+ .map((name) => name.charAt(0))
12
+ .join("")
13
+ .toUpperCase();
14
+
15
+ return abbreviation.toUpperCase();
16
+ };