@akinon/next 2.0.6-rc.1 → 2.0.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.
- package/CHANGELOG.md +5 -27
- package/api/auth.ts +84 -24
- package/api/client.ts +32 -0
- package/bin/pz-generate-routes.js +1 -4
- package/components/client-root.tsx +2 -0
- package/components/plugin-module.tsx +9 -3
- package/components/toast.tsx +258 -0
- package/data/client/checkout.ts +1 -0
- package/data/server/category.ts +2 -14
- package/data/server/list.ts +1 -13
- package/data/server/product.ts +0 -10
- package/data/server/special-page.ts +1 -14
- package/data/server/widget.ts +1 -14
- package/data/urls.ts +1 -5
- package/hooks/index.ts +1 -0
- package/hooks/use-captcha.tsx +1 -1
- package/hooks/use-toast.ts +56 -0
- package/instrumentation/index.ts +1 -0
- package/instrumentation/node.ts +224 -2
- package/lib/fixture-manager.ts +146 -0
- package/middlewares/default.ts +1 -2
- package/middlewares/masterpass-rest-callback.ts +147 -34
- package/package.json +6 -7
- package/plugins.d.ts +0 -10
- package/plugins.js +0 -1
- package/redux/actions.ts +1 -0
- package/redux/middlewares/checkout.ts +3 -45
- package/redux/middlewares/pre-order/installment-option.ts +1 -9
- package/redux/reducers/index.ts +2 -0
- package/redux/reducers/toast.ts +70 -0
- package/types/commerce/flatpage.ts +7 -0
- package/types/index.ts +0 -7
- package/utils/app-fetch.ts +27 -0
- package/utils/format-error-message.ts +7 -0
- package/utils/index.ts +8 -1
- package/with-pz-config.js +3 -3
- package/utils/payload-optimizer.ts +0 -481
|
@@ -4,9 +4,6 @@ import { GetCategoryResponse, SearchParams } from '../../types';
|
|
|
4
4
|
import { generateCommerceSearchParams } from '../../utils';
|
|
5
5
|
import appFetch from '../../utils/app-fetch';
|
|
6
6
|
import { ServerVariables } from '../../utils/server-variables';
|
|
7
|
-
import { optimizeCategoryResponse } from '../../utils/payload-optimizer';
|
|
8
|
-
import logger from '../../utils/log';
|
|
9
|
-
import settings from 'settings';
|
|
10
7
|
|
|
11
8
|
const getSpecialPageDataHandler = (
|
|
12
9
|
pk: number,
|
|
@@ -48,7 +45,7 @@ export const getSpecialPageData = async ({
|
|
|
48
45
|
searchParams: SearchParams;
|
|
49
46
|
headers?: Record<string, string>;
|
|
50
47
|
}) => {
|
|
51
|
-
|
|
48
|
+
return Cache.wrap(
|
|
52
49
|
CacheKey.SpecialPage(pk, searchParams, headers),
|
|
53
50
|
locale,
|
|
54
51
|
getSpecialPageDataHandler(pk, locale, currency, searchParams, headers),
|
|
@@ -57,14 +54,4 @@ export const getSpecialPageData = async ({
|
|
|
57
54
|
compressed: true
|
|
58
55
|
}
|
|
59
56
|
);
|
|
60
|
-
|
|
61
|
-
if (settings.payloadOptimization?.enabled && result) {
|
|
62
|
-
try {
|
|
63
|
-
return optimizeCategoryResponse(result, settings.payloadOptimization);
|
|
64
|
-
} catch (e) {
|
|
65
|
-
logger.error('Payload optimization failed for special-page', { pk, error: (e as Error).message });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return result;
|
|
70
57
|
};
|
package/data/server/widget.ts
CHANGED
|
@@ -4,9 +4,6 @@ import { CacheOptions, WidgetResultType, WidgetSchemaType } from '../../types';
|
|
|
4
4
|
import appFetch from '../../utils/app-fetch';
|
|
5
5
|
import { widgets } from '../urls';
|
|
6
6
|
import { ServerVariables } from '../../utils/server-variables';
|
|
7
|
-
import { optimizeWidgetResponse } from '../../utils/payload-optimizer';
|
|
8
|
-
import logger from '../../utils/log';
|
|
9
|
-
import settings from 'settings';
|
|
10
7
|
|
|
11
8
|
const getWidgetDataHandler =
|
|
12
9
|
(
|
|
@@ -56,7 +53,7 @@ export const getWidgetData = async <T>({
|
|
|
56
53
|
cacheOptions?: CacheOptions;
|
|
57
54
|
headers?: Record<string, string>;
|
|
58
55
|
}): Promise<WidgetResultType<T>> => {
|
|
59
|
-
|
|
56
|
+
return Cache.wrap(
|
|
60
57
|
CacheKey.Widget(slug),
|
|
61
58
|
locale,
|
|
62
59
|
getWidgetDataHandler(slug, locale, currency, headers),
|
|
@@ -65,16 +62,6 @@ export const getWidgetData = async <T>({
|
|
|
65
62
|
...cacheOptions
|
|
66
63
|
}
|
|
67
64
|
);
|
|
68
|
-
|
|
69
|
-
if (settings.payloadOptimization?.enabled && result) {
|
|
70
|
-
try {
|
|
71
|
-
return optimizeWidgetResponse(result, settings.payloadOptimization) as WidgetResultType<T>;
|
|
72
|
-
} catch (e) {
|
|
73
|
-
logger.error('Payload optimization failed for widget', { slug, error: (e as Error).message });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return result as WidgetResultType<T>;
|
|
78
65
|
};
|
|
79
66
|
|
|
80
67
|
const getCollectionWidgetDataHandler =
|
package/data/urls.ts
CHANGED
|
@@ -183,11 +183,7 @@ export const product = {
|
|
|
183
183
|
breadcrumbUrl: (menuitemmodel: string) =>
|
|
184
184
|
`/menus/generate_breadcrumb/?item=${menuitemmodel}&generator_name=menu_item`,
|
|
185
185
|
bundleProduct: (productPk: string, queryString: string) =>
|
|
186
|
-
`/bundle-product/${productPk}/?${queryString}
|
|
187
|
-
similarProducts: (params?: string) =>
|
|
188
|
-
`/similar-products${params ? `?${params}` : ''}`,
|
|
189
|
-
similarProductsList: (params?: string) =>
|
|
190
|
-
`/similar-product-list${params ? `?${params}` : ''}`
|
|
186
|
+
`/bundle-product/${productPk}/?${queryString}`
|
|
191
187
|
};
|
|
192
188
|
|
|
193
189
|
export const wishlist = {
|
package/hooks/index.ts
CHANGED
package/hooks/use-captcha.tsx
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useAppDispatch } from '../redux/hooks'
|
|
5
|
+
import {
|
|
6
|
+
addToast,
|
|
7
|
+
removeToast,
|
|
8
|
+
ToastType,
|
|
9
|
+
ToastInput
|
|
10
|
+
} from '../redux/reducers/toast'
|
|
11
|
+
|
|
12
|
+
export type ToastOptions = Omit<ToastInput, 'type' | 'message'>
|
|
13
|
+
|
|
14
|
+
export const useToast = () => {
|
|
15
|
+
const dispatch = useAppDispatch()
|
|
16
|
+
|
|
17
|
+
const toast = useCallback(
|
|
18
|
+
(type: ToastType, message: string, options?: ToastOptions) => {
|
|
19
|
+
dispatch(addToast({ type, message, ...options }))
|
|
20
|
+
},
|
|
21
|
+
[dispatch]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const success = useCallback(
|
|
25
|
+
(message: string, options?: ToastOptions) =>
|
|
26
|
+
toast('success', message, options),
|
|
27
|
+
[toast]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const error = useCallback(
|
|
31
|
+
(message: string, options?: ToastOptions) =>
|
|
32
|
+
toast('error', message, options),
|
|
33
|
+
[toast]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const warning = useCallback(
|
|
37
|
+
(message: string, options?: ToastOptions) =>
|
|
38
|
+
toast('warning', message, options),
|
|
39
|
+
[toast]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const info = useCallback(
|
|
43
|
+
(message: string, options?: ToastOptions) =>
|
|
44
|
+
toast('info', message, options),
|
|
45
|
+
[toast]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const dismiss = useCallback(
|
|
49
|
+
(id: string) => {
|
|
50
|
+
dispatch(removeToast(id))
|
|
51
|
+
},
|
|
52
|
+
[dispatch]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return { toast, success, error, warning, info, dismiss }
|
|
56
|
+
}
|
package/instrumentation/index.ts
CHANGED
package/instrumentation/node.ts
CHANGED
|
@@ -1,2 +1,224 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { context, trace, type SpanAttributes } from '@opentelemetry/api';
|
|
2
|
+
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
|
3
|
+
import { ExportResultCode, type ExportResult } from '@opentelemetry/core';
|
|
4
|
+
import {
|
|
5
|
+
BasicTracerProvider,
|
|
6
|
+
SimpleSpanProcessor,
|
|
7
|
+
type ReadableSpan,
|
|
8
|
+
type SpanExporter
|
|
9
|
+
} from '@opentelemetry/sdk-trace-base';
|
|
10
|
+
|
|
11
|
+
type OTelAttributeValue =
|
|
12
|
+
| { stringValue: string }
|
|
13
|
+
| { intValue: number }
|
|
14
|
+
| { doubleValue: number }
|
|
15
|
+
| { boolValue: boolean };
|
|
16
|
+
|
|
17
|
+
const PZ_DASHBOARD_TRACING_STARTED_KEY = '__pzDashboardTracingStarted__';
|
|
18
|
+
const SERVICE_NAME = 'pz-next-app';
|
|
19
|
+
const DASHBOARD_FALLBACK_URL = 'http://localhost:3005';
|
|
20
|
+
|
|
21
|
+
type PzDashboardGlobal = typeof globalThis & {
|
|
22
|
+
[PZ_DASHBOARD_TRACING_STARTED_KEY]?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const getDashboardTraceUrl = () => {
|
|
26
|
+
const baseUrl = process.env.PZ_DASHBOARD_URL ?? DASHBOARD_FALLBACK_URL;
|
|
27
|
+
|
|
28
|
+
return `${baseUrl.replace(/\/$/, '')}/api/traces`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hrTimeToUnixNano = ([seconds, nanos]: [number, number]) =>
|
|
32
|
+
(BigInt(seconds) * BigInt(1000000000) + BigInt(nanos)).toString();
|
|
33
|
+
|
|
34
|
+
const getAttributeValue = (value: unknown): OTelAttributeValue => {
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
return { stringValue: value };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
return Number.isInteger(value)
|
|
41
|
+
? { intValue: value }
|
|
42
|
+
: { doubleValue: value };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof value === 'boolean') {
|
|
46
|
+
return { boolValue: value };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { stringValue: JSON.stringify(value ?? '') };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const getAttributes = (attributes: SpanAttributes = {}) =>
|
|
53
|
+
Object.entries(attributes)
|
|
54
|
+
.filter(([, value]) => value !== undefined)
|
|
55
|
+
.map(([key, value]) => ({
|
|
56
|
+
key,
|
|
57
|
+
value: getAttributeValue(value)
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const getParentSpanId = (span: ReadableSpan) => {
|
|
61
|
+
if ('parentSpanId' in span && typeof span.parentSpanId === 'string') {
|
|
62
|
+
return span.parentSpanId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
'parentSpanContext' in span &&
|
|
67
|
+
span.parentSpanContext &&
|
|
68
|
+
typeof span.parentSpanContext === 'object' &&
|
|
69
|
+
'spanId' in span.parentSpanContext
|
|
70
|
+
) {
|
|
71
|
+
return String(span.parentSpanContext.spanId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const getResourceAttributes = (span: ReadableSpan) => ({
|
|
78
|
+
...(span.resource?.attributes ?? {}),
|
|
79
|
+
'service.name':
|
|
80
|
+
process.env.OTEL_SERVICE_NAME ??
|
|
81
|
+
process.env.NEXT_PUBLIC_APP_NAME ??
|
|
82
|
+
SERVICE_NAME
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const getScope = (span: ReadableSpan) => {
|
|
86
|
+
const scopedSpan = span as ReadableSpan & {
|
|
87
|
+
instrumentationLibrary?: {
|
|
88
|
+
name?: string;
|
|
89
|
+
version?: string;
|
|
90
|
+
};
|
|
91
|
+
instrumentationScope?: {
|
|
92
|
+
name?: string;
|
|
93
|
+
version?: string;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const scope =
|
|
97
|
+
scopedSpan.instrumentationScope ?? scopedSpan.instrumentationLibrary;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
name: scope?.name ?? SERVICE_NAME,
|
|
101
|
+
version: scope?.version ?? ''
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const getSpanPayload = (span: ReadableSpan) => ({
|
|
106
|
+
traceId: span.spanContext().traceId,
|
|
107
|
+
spanId: span.spanContext().spanId,
|
|
108
|
+
parentSpanId: getParentSpanId(span),
|
|
109
|
+
name: span.name,
|
|
110
|
+
kind: span.kind,
|
|
111
|
+
startTimeUnixNano: hrTimeToUnixNano(span.startTime),
|
|
112
|
+
endTimeUnixNano: hrTimeToUnixNano(span.endTime),
|
|
113
|
+
attributes: getAttributes(span.attributes),
|
|
114
|
+
droppedAttributesCount: span.droppedAttributesCount ?? 0,
|
|
115
|
+
events:
|
|
116
|
+
span.events?.map((event) => ({
|
|
117
|
+
name: event.name,
|
|
118
|
+
timeUnixNano: hrTimeToUnixNano(event.time),
|
|
119
|
+
attributes: getAttributes(event.attributes),
|
|
120
|
+
droppedAttributesCount: event.droppedAttributesCount ?? 0
|
|
121
|
+
})) ?? [],
|
|
122
|
+
droppedEventsCount: span.droppedEventsCount ?? 0,
|
|
123
|
+
status: span.status,
|
|
124
|
+
links:
|
|
125
|
+
span.links?.map((link) => ({
|
|
126
|
+
traceId: link.context.traceId,
|
|
127
|
+
spanId: link.context.spanId,
|
|
128
|
+
attributes: getAttributes(link.attributes),
|
|
129
|
+
droppedAttributesCount: link.droppedAttributesCount ?? 0
|
|
130
|
+
})) ?? [],
|
|
131
|
+
droppedLinksCount: span.droppedLinksCount ?? 0
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
class PzDashboardTraceExporter implements SpanExporter {
|
|
135
|
+
constructor(private readonly url: string) {}
|
|
136
|
+
|
|
137
|
+
export(
|
|
138
|
+
spans: ReadableSpan[],
|
|
139
|
+
resultCallback: (result: ExportResult) => void
|
|
140
|
+
) {
|
|
141
|
+
const [firstSpan] = spans;
|
|
142
|
+
|
|
143
|
+
if (!firstSpan) {
|
|
144
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
void this.sendSpans(spans, firstSpan, resultCallback);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async sendSpans(
|
|
152
|
+
spans: ReadableSpan[],
|
|
153
|
+
firstSpan: ReadableSpan,
|
|
154
|
+
resultCallback: (result: ExportResult) => void
|
|
155
|
+
) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(this.url, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
resourceSpans: [
|
|
162
|
+
{
|
|
163
|
+
resource: {
|
|
164
|
+
attributes: getAttributes(getResourceAttributes(firstSpan)),
|
|
165
|
+
droppedAttributesCount: 0
|
|
166
|
+
},
|
|
167
|
+
scopeSpans: spans.map((span) => ({
|
|
168
|
+
scope: getScope(span),
|
|
169
|
+
spans: [getSpanPayload(span)]
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
})
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
throw new Error(`PZ Dashboard trace export failed: ${response.status}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
resultCallback({
|
|
183
|
+
code: ExportResultCode.FAILED,
|
|
184
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
shutdown() {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const startDashboardTracing = () => {
|
|
195
|
+
const pzDashboardGlobal = globalThis as PzDashboardGlobal;
|
|
196
|
+
|
|
197
|
+
if (pzDashboardGlobal[PZ_DASHBOARD_TRACING_STARTED_KEY]) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const provider = new BasicTracerProvider({
|
|
202
|
+
spanProcessors: [
|
|
203
|
+
new SimpleSpanProcessor(
|
|
204
|
+
new PzDashboardTraceExporter(getDashboardTraceUrl())
|
|
205
|
+
)
|
|
206
|
+
]
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
context.setGlobalContextManager(
|
|
210
|
+
new AsyncLocalStorageContextManager().enable()
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const registered = trace.setGlobalTracerProvider(provider);
|
|
214
|
+
|
|
215
|
+
if (!registered) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pzDashboardGlobal[PZ_DASHBOARD_TRACING_STARTED_KEY] = true;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (process.env.NODE_ENV === 'development') {
|
|
223
|
+
startDashboardTracing();
|
|
224
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import * as crypto from 'crypto'
|
|
4
|
+
import logger from '../utils/log'
|
|
5
|
+
|
|
6
|
+
export const MockMode = {
|
|
7
|
+
RECORD: 'record',
|
|
8
|
+
REPLAY: 'replay'
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
export type MockMode = (typeof MockMode)[keyof typeof MockMode]
|
|
12
|
+
|
|
13
|
+
interface FixtureData {
|
|
14
|
+
request: {
|
|
15
|
+
method: string
|
|
16
|
+
url: string
|
|
17
|
+
body?: any
|
|
18
|
+
}
|
|
19
|
+
response: {
|
|
20
|
+
status: number
|
|
21
|
+
headers: Record<string, string>
|
|
22
|
+
body: any
|
|
23
|
+
}
|
|
24
|
+
recorded_at: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SENSITIVE_HEADERS = [
|
|
28
|
+
'authorization',
|
|
29
|
+
'cookie',
|
|
30
|
+
'set-cookie',
|
|
31
|
+
'x-csrftoken',
|
|
32
|
+
'x-forwarded-for'
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
class FixtureManager {
|
|
36
|
+
private fixturesDir: string
|
|
37
|
+
private dirEnsured = false
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
this.fixturesDir = path.join(process.cwd(), '.pz-fixtures')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private ensureDir(): void {
|
|
44
|
+
if (this.dirEnsured) return
|
|
45
|
+
fs.mkdirSync(this.fixturesDir, { recursive: true })
|
|
46
|
+
this.dirEnsured = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
generateKey(method: string, url: string, body?: any): string {
|
|
50
|
+
const normalized = method?.toUpperCase() ?? 'GET'
|
|
51
|
+
const parts = [normalized, url]
|
|
52
|
+
|
|
53
|
+
if (body && normalized !== 'GET') {
|
|
54
|
+
const bodyStr =
|
|
55
|
+
typeof body === 'string' ? body : JSON.stringify(body)
|
|
56
|
+
parts.push(bodyStr)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return crypto.createHash('md5').update(parts.join(':')).digest('hex')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
extractHeaders(headers: Headers): Record<string, string> {
|
|
63
|
+
const result: Record<string, string> = {}
|
|
64
|
+
headers.forEach((value, key) => {
|
|
65
|
+
result[key] = value
|
|
66
|
+
})
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private stripSensitiveHeaders(
|
|
71
|
+
headers: Record<string, string>
|
|
72
|
+
): Record<string, string> {
|
|
73
|
+
const cleaned: Record<string, string> = {}
|
|
74
|
+
|
|
75
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
76
|
+
if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) {
|
|
77
|
+
cleaned[key] = value
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return cleaned
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async write(
|
|
85
|
+
method: string,
|
|
86
|
+
url: string,
|
|
87
|
+
body: any,
|
|
88
|
+
response: { status: number; headers: Record<string, string>; body: any }
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
this.ensureDir()
|
|
92
|
+
|
|
93
|
+
const normalized = method?.toUpperCase() ?? 'GET'
|
|
94
|
+
const key = this.generateKey(normalized, url, body)
|
|
95
|
+
const fixture: FixtureData = {
|
|
96
|
+
request: {
|
|
97
|
+
method: normalized,
|
|
98
|
+
url
|
|
99
|
+
},
|
|
100
|
+
response: {
|
|
101
|
+
status: response.status,
|
|
102
|
+
headers: this.stripSensitiveHeaders(response.headers),
|
|
103
|
+
body: response.body
|
|
104
|
+
},
|
|
105
|
+
recorded_at: new Date().toISOString()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (body && normalized !== 'GET') {
|
|
109
|
+
fixture.request.body = body
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const filePath = path.join(this.fixturesDir, `${key}.json`)
|
|
113
|
+
await fs.promises.writeFile(filePath, JSON.stringify(fixture, null, 2))
|
|
114
|
+
|
|
115
|
+
logger.debug(`[pz-mock] Recorded fixture: ${normalized} ${url} → ${filePath}`)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error(`[pz-mock] Failed to write fixture`, { url, error })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async read(
|
|
122
|
+
method: string,
|
|
123
|
+
url: string,
|
|
124
|
+
body?: any
|
|
125
|
+
): Promise<{ found: boolean; fixture?: FixtureData }> {
|
|
126
|
+
try {
|
|
127
|
+
const key = this.generateKey(method, url, body)
|
|
128
|
+
const filePath = path.join(this.fixturesDir, `${key}.json`)
|
|
129
|
+
|
|
130
|
+
const raw = await fs.promises.readFile(filePath, 'utf-8')
|
|
131
|
+
const fixture = JSON.parse(raw) as FixtureData
|
|
132
|
+
|
|
133
|
+
logger.debug(`[pz-mock] Replaying fixture: ${method} ${url} → ${key}`)
|
|
134
|
+
return { found: true, fixture }
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
137
|
+
logger.warn(`[pz-mock] No fixture found: ${method} ${url}`)
|
|
138
|
+
return { found: false }
|
|
139
|
+
}
|
|
140
|
+
logger.error(`[pz-mock] Failed to read fixture`, { url, error })
|
|
141
|
+
return { found: false }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const fixtureManager = new FixtureManager()
|