@dotdev/harmony-sdk 1.31.0 → 1.32.0

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.
@@ -1,11 +1,17 @@
1
+ export type HarmonyErrorDetails = {
2
+ status?: number;
3
+ code?: string;
4
+ };
1
5
  export declare class HarmonyAPIError extends Error {
2
- constructor(message: string);
6
+ readonly status?: number;
7
+ readonly code?: string;
8
+ constructor(message: string, details?: HarmonyErrorDetails);
3
9
  }
4
10
  export declare class AuthenticationError extends HarmonyAPIError {
5
- constructor(message: string);
11
+ constructor(message: string, details?: HarmonyErrorDetails);
6
12
  }
7
13
  export declare class RequestError extends HarmonyAPIError {
8
- constructor(message: string);
14
+ constructor(message: string, details?: HarmonyErrorDetails);
9
15
  }
10
16
  export interface ApiError {
11
17
  status?: string;
@@ -1,18 +1,22 @@
1
1
  export class HarmonyAPIError extends Error {
2
- constructor(message) {
2
+ status;
3
+ code;
4
+ constructor(message, details) {
3
5
  super(message);
4
6
  this.name = "HarmonyAPIError";
7
+ this.status = details?.status;
8
+ this.code = details?.code;
5
9
  }
6
10
  }
7
11
  export class AuthenticationError extends HarmonyAPIError {
8
- constructor(message) {
9
- super(message);
12
+ constructor(message, details) {
13
+ super(message, details);
10
14
  this.name = "AuthenticationError";
11
15
  }
12
16
  }
13
17
  export class RequestError extends HarmonyAPIError {
14
- constructor(message) {
15
- super(message);
18
+ constructor(message, details) {
19
+ super(message, details);
16
20
  this.name = "RequestError";
17
21
  }
18
22
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { AuthenticationError, HarmonyAPIError, RequestError, } from "./index";
2
+ describe("HarmonyAPIError", () => {
3
+ it("constructs with message only (backward compat)", () => {
4
+ const e = new HarmonyAPIError("boom");
5
+ expect(e.message).toBe("boom");
6
+ expect(e.name).toBe("HarmonyAPIError");
7
+ expect(e.status).toBeUndefined();
8
+ expect(e.code).toBeUndefined();
9
+ });
10
+ it("accepts optional status and code via details arg", () => {
11
+ const e = new HarmonyAPIError("boom", { status: 400, code: "S:Server" });
12
+ expect(e.message).toBe("boom");
13
+ expect(e.status).toBe(400);
14
+ expect(e.code).toBe("S:Server");
15
+ });
16
+ it("accepts partial details (status only, code only)", () => {
17
+ const s = new HarmonyAPIError("a", { status: 500 });
18
+ expect(s.status).toBe(500);
19
+ expect(s.code).toBeUndefined();
20
+ const c = new HarmonyAPIError("b", { code: "X" });
21
+ expect(c.status).toBeUndefined();
22
+ expect(c.code).toBe("X");
23
+ });
24
+ it("is instanceof Error", () => {
25
+ expect(new HarmonyAPIError("x")).toBeInstanceOf(Error);
26
+ });
27
+ });
28
+ describe("RequestError", () => {
29
+ it("inherits HarmonyAPIError and exposes status/code", () => {
30
+ const e = new RequestError("req fail", { status: 502, code: "" });
31
+ expect(e).toBeInstanceOf(HarmonyAPIError);
32
+ expect(e).toBeInstanceOf(Error);
33
+ expect(e.name).toBe("RequestError");
34
+ expect(e.status).toBe(502);
35
+ expect(e.code).toBe("");
36
+ });
37
+ it("constructs with message only (backward compat)", () => {
38
+ const e = new RequestError("just msg");
39
+ expect(e.message).toBe("just msg");
40
+ expect(e.status).toBeUndefined();
41
+ expect(e.code).toBeUndefined();
42
+ });
43
+ });
44
+ describe("AuthenticationError", () => {
45
+ it("inherits HarmonyAPIError and supports details arg", () => {
46
+ const e = new AuthenticationError("no token", { status: 401 });
47
+ expect(e).toBeInstanceOf(HarmonyAPIError);
48
+ expect(e.name).toBe("AuthenticationError");
49
+ expect(e.status).toBe(401);
50
+ });
51
+ it("constructs with message only (backward compat)", () => {
52
+ const e = new AuthenticationError("no token");
53
+ expect(e.message).toBe("no token");
54
+ expect(e.status).toBeUndefined();
55
+ });
56
+ });
@@ -99,33 +99,42 @@ export class ApiHelper {
99
99
  }
100
100
  static async parseError(error) {
101
101
  if (axios.isAxiosError(error)) {
102
+ const status = error?.response?.status;
102
103
  const childLogger = logger.child({
103
104
  error: {
104
105
  config: error?.config,
105
106
  request: error?.request,
106
107
  data: error?.response?.data,
107
- status: error?.response?.status,
108
+ status,
108
109
  },
109
110
  });
110
111
  if (error?.response) {
112
+ const statusCategory = statusToCategory(status);
111
113
  if (Utils.isValidXml(error?.response?.data)) {
112
114
  const parsedError = await ApiHelper.parseHarmonyErrorResponse(error?.response?.data);
113
- childLogger.error(`Harmony request error`);
114
- return new RequestError(`API request failed with status ${error.response.status}: {code: ${parsedError?.code}, name: ${parsedError?.name}, message: ${parsedError?.message}}`);
115
+ childLogger.error({
116
+ statusCategory,
117
+ harmonyCode: parsedError?.code,
118
+ harmonyName: parsedError?.name,
119
+ harmonyMessage: parsedError?.message,
120
+ }, `Harmony request error`);
121
+ return new RequestError(`API request failed with status ${error.response.status}: {code: ${parsedError?.code}, name: ${parsedError?.name}, message: ${parsedError?.message}}`, { status, code: parsedError?.code });
115
122
  }
116
123
  else {
117
- childLogger.error(`API request error due to response not in Harmony XML format`);
118
- return new RequestError(`API request failed but error response is not in Harmony XML format. Please check the logs for more details.`);
124
+ childLogger.error({ statusCategory }, `API request error due to response not in Harmony XML format`);
125
+ return new RequestError(`API request failed but error response is not in Harmony XML format. Please check the logs for more details.`, { status });
119
126
  }
120
127
  }
121
128
  else if (error?.request) {
129
+ childLogger.error({ statusCategory: "no_response" }, `No response received from Harmony API`);
122
130
  return new RequestError("No response received from the server during API call");
123
131
  }
124
132
  else {
133
+ childLogger.error({ statusCategory: "setup_failure" }, `Harmony API request setup failed`);
125
134
  return new RequestError(`API request setup failed: ${error?.message}`);
126
135
  }
127
136
  }
128
- logger.error(error, `⛔️ Unexpected error occurred during API request`);
137
+ logger.error({ err: error, statusCategory: "unexpected" }, `⛔️ Unexpected error occurred during API request`);
129
138
  return new RequestError(`Unexpected error occurred during API request`);
130
139
  }
131
140
  /**
@@ -161,6 +170,15 @@ export class ApiHelper {
161
170
  };
162
171
  }
163
172
  }
173
+ function statusToCategory(status) {
174
+ if (status === undefined)
175
+ return "unknown";
176
+ if (status >= 400 && status < 500)
177
+ return "client_error";
178
+ if (status >= 500 && status < 600)
179
+ return "server_error";
180
+ return "unknown";
181
+ }
164
182
  /**
165
183
  * Check whether the provided parameters meet the specified limits. If any parameter exceeds the limit, an error will be thrown.
166
184
  * If all checks passed, return true.
@@ -1,5 +1,6 @@
1
1
  import axios from "axios";
2
2
  import { ApiHelper, checkParamLimits, TEST_HEADERS, Utils } from ".";
3
+ import { RequestError } from "../errors";
3
4
  import { ServiceAlias } from "../modules/shared/types";
4
5
  // Increase timeout due to Harmony API having long response time
5
6
  jest.setTimeout(999999);
@@ -146,3 +147,131 @@ describe("parseRequestError", () => {
146
147
  expect(await ApiHelper.parseHarmonyErrorResponse(errResp)).toEqual(expected);
147
148
  });
148
149
  });
150
+ describe("ApiHelper.parseError", () => {
151
+ // Note: XML declaration must be at position 0 — no leading whitespace, or
152
+ // fast-xml-parser's XMLValidator rejects it and parseError takes the non-XML branch.
153
+ const HARMONY_XML_FAULT = `<?xml version='1.0' encoding='UTF-8'?><S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body><S:Fault xmlns:ns4="http://www.w3.org/2003/05/soap-envelope"><faultcode>S:Server</faultcode><faultstring>Failed Test Request</faultstring><detail><ns2:ServiceFault xmlns:ns2="http://pos.ws.fbsaust.com.au"><errorDetails>[204-104-2408071537-0000851206] (Invalid value)</errorDetails></ns2:ServiceFault></detail></S:Fault></S:Body></S:Envelope>`;
154
+ const GIFT_CARD_SERIAL_FAULT = `<?xml version='1.0' encoding='UTF-8'?><S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body><S:Fault><faultcode>S:Server</faultcode><faultstring>Validation</faultstring><detail><ns2:ServiceFault xmlns:ns2="http://gv.ws.fbsaust.com.au"><errorDetails>SN00001 is not a valid Serial Number value</errorDetails></ns2:ServiceFault></detail></S:Fault></S:Body></S:Envelope>`;
155
+ const makeAxiosError = (overrides) => {
156
+ const err = new Error(overrides.message ?? "Request failed");
157
+ err.isAxiosError = true;
158
+ err.config = {};
159
+ if (overrides.response)
160
+ err.response = overrides.response;
161
+ if (overrides.request)
162
+ err.request = overrides.request;
163
+ return err;
164
+ };
165
+ // axios.isAxiosError is auto-mocked by jest.mock("axios") at top of file.
166
+ // Point it at real behaviour per-test so constructed errors pass isAxiosError().
167
+ // Also silence the pino logger and capture structured context passed to it.
168
+ const { logger: realLogger } = require("./logger");
169
+ let childErrorSpy;
170
+ beforeEach(() => {
171
+ mAxios.isAxiosError.mockImplementation((e) => !!e?.isAxiosError);
172
+ childErrorSpy = jest.fn();
173
+ jest.spyOn(realLogger, "error").mockImplementation(jest.fn());
174
+ jest.spyOn(realLogger, "child").mockReturnValue({
175
+ error: childErrorSpy,
176
+ debug: jest.fn(),
177
+ info: jest.fn(),
178
+ warn: jest.fn(),
179
+ });
180
+ });
181
+ afterEach(() => {
182
+ jest.restoreAllMocks();
183
+ });
184
+ it("populates status and code for 4xx with Harmony XML body", async () => {
185
+ const err = makeAxiosError({
186
+ response: { status: 400, data: HARMONY_XML_FAULT },
187
+ });
188
+ const result = await ApiHelper.parseError(err);
189
+ expect(result).toBeInstanceOf(RequestError);
190
+ expect(result.status).toBe(400);
191
+ expect(result.code).toBe("S:Server");
192
+ expect(result.message).toBe(`API request failed with status 400: {code: S:Server, name: Failed Test Request, message: [204-104-2408071537-0000851206] (Invalid value)}`);
193
+ });
194
+ it("populates status for 5xx with Harmony XML body", async () => {
195
+ const err = makeAxiosError({
196
+ response: { status: 500, data: HARMONY_XML_FAULT },
197
+ });
198
+ const result = await ApiHelper.parseError(err);
199
+ expect(result.status).toBe(500);
200
+ expect(result.code).toBe("S:Server");
201
+ });
202
+ it("populates status but no code for non-XML 4xx response", async () => {
203
+ const err = makeAxiosError({
204
+ response: { status: 404, data: "Not Found" },
205
+ });
206
+ const result = await ApiHelper.parseError(err);
207
+ expect(result.status).toBe(404);
208
+ expect(result.code).toBeUndefined();
209
+ expect(result.message).toBe(`API request failed but error response is not in Harmony XML format. Please check the logs for more details.`);
210
+ });
211
+ it("populates status but no code for non-XML 5xx response", async () => {
212
+ const err = makeAxiosError({
213
+ response: { status: 502, data: "502 Bad Gateway" },
214
+ });
215
+ const result = await ApiHelper.parseError(err);
216
+ expect(result.status).toBe(502);
217
+ expect(result.code).toBeUndefined();
218
+ });
219
+ it("returns no status when request was sent but no response received", async () => {
220
+ const err = makeAxiosError({ request: {} });
221
+ const result = await ApiHelper.parseError(err);
222
+ expect(result.status).toBeUndefined();
223
+ expect(result.code).toBeUndefined();
224
+ expect(result.message).toBe("No response received from the server during API call");
225
+ });
226
+ it("returns no status for axios setup failure", async () => {
227
+ const err = makeAxiosError({ message: "bad URL" });
228
+ const result = await ApiHelper.parseError(err);
229
+ expect(result.status).toBeUndefined();
230
+ expect(result.message).toBe("API request setup failed: bad URL");
231
+ });
232
+ it("returns a RequestError for non-axios errors (fallthrough)", async () => {
233
+ const result = await ApiHelper.parseError(new Error("random"));
234
+ expect(result).toBeInstanceOf(RequestError);
235
+ expect(result.status).toBeUndefined();
236
+ expect(result.message).toBe("Unexpected error occurred during API request");
237
+ });
238
+ it("preserves gift-card serial-number substring in message (backward-compat)", async () => {
239
+ const err = makeAxiosError({
240
+ response: { status: 400, data: GIFT_CARD_SERIAL_FAULT },
241
+ });
242
+ const result = await ApiHelper.parseError(err);
243
+ // core-v2 services/gift-cards/strategies/harmony.ts:158 does
244
+ // err.message.includes("is not a valid Serial Number value") — must remain true.
245
+ expect(result.message.includes("is not a valid Serial Number value")).toBe(true);
246
+ });
247
+ it("logs structured fields for 4xx XML (statusCategory=client_error + harmonyCode)", async () => {
248
+ const err = makeAxiosError({
249
+ response: { status: 400, data: HARMONY_XML_FAULT },
250
+ });
251
+ await ApiHelper.parseError(err);
252
+ expect(childErrorSpy).toHaveBeenCalledTimes(1);
253
+ const [ctx, msg] = childErrorSpy.mock.calls[0];
254
+ expect(ctx).toMatchObject({
255
+ statusCategory: "client_error",
256
+ harmonyCode: "S:Server",
257
+ harmonyName: "Failed Test Request",
258
+ harmonyMessage: "[204-104-2408071537-0000851206] (Invalid value)",
259
+ });
260
+ expect(msg).toBe("Harmony request error");
261
+ });
262
+ it("logs statusCategory=server_error for 5xx", async () => {
263
+ const err = makeAxiosError({
264
+ response: { status: 500, data: HARMONY_XML_FAULT },
265
+ });
266
+ await ApiHelper.parseError(err);
267
+ const [ctx] = childErrorSpy.mock.calls[0];
268
+ expect(ctx).toMatchObject({ statusCategory: "server_error" });
269
+ });
270
+ it("logs statusCategory=no_response when request never got a response", async () => {
271
+ const err = makeAxiosError({ request: {} });
272
+ await ApiHelper.parseError(err);
273
+ const [ctx, msg] = childErrorSpy.mock.calls[0];
274
+ expect(ctx).toMatchObject({ statusCategory: "no_response" });
275
+ expect(msg).toBe("No response received from Harmony API");
276
+ });
277
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdev/harmony-sdk",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Harmony API SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",