@dotdev/harmony-sdk 1.31.0 → 1.32.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.
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { AxiosInstance } from "axios";
2
- import { ApiError, RequestError } from "../errors";
2
+ import { ApiError, HarmonyAPIError } from "../errors";
3
3
  import { ParamLimits, ServiceAlias } from "../modules";
4
4
  export * from "./utils";
5
5
  export declare abstract class ApiHelper {
@@ -26,7 +26,7 @@ export declare abstract class ApiHelper {
26
26
  * @returns {Promise<R>}
27
27
  */
28
28
  static sendServiceRequest<R>(serviceName: string, methodName: string | string[], sessionId: string, serviceAlias: ServiceAlias, axios: AxiosInstance, body?: string): Promise<R>;
29
- static parseError(error: any): Promise<RequestError>;
29
+ static parseError(error: any): Promise<HarmonyAPIError>;
30
30
  /**
31
31
  * Extract error response from Harmony in XML format and convert it to JSON format for ease of debugging
32
32
  *
@@ -1,7 +1,7 @@
1
1
  import axios from "axios";
2
2
  import { promisify } from "util";
3
3
  import { parseString } from "xml2js";
4
- import { RequestError } from "../errors";
4
+ import { HarmonyAPIError, RequestError } from "../errors";
5
5
  import { logger } from "./logger";
6
6
  import { Utils } from "./utils";
7
7
  export * from "./utils";
@@ -98,34 +98,53 @@ export class ApiHelper {
98
98
  return response[responseMethodName][0];
99
99
  }
100
100
  static async parseError(error) {
101
+ // An already-parsed Harmony error (re-thrown up through a module-level
102
+ // catch) must pass through unchanged. Re-parsing it would hit the
103
+ // non-axios fallthrough below and discard its status/code, masking the
104
+ // real fault (e.g. a 500 "Invalid Session") behind the generic
105
+ // "Unexpected error" message before it reaches the caller. The first
106
+ // parse already logged this fault, so we deliberately don't re-log it
107
+ // here. [DAT-2135]
108
+ if (error instanceof HarmonyAPIError) {
109
+ return error;
110
+ }
101
111
  if (axios.isAxiosError(error)) {
112
+ const status = error?.response?.status;
102
113
  const childLogger = logger.child({
103
114
  error: {
104
115
  config: error?.config,
105
116
  request: error?.request,
106
117
  data: error?.response?.data,
107
- status: error?.response?.status,
118
+ status,
108
119
  },
109
120
  });
110
121
  if (error?.response) {
122
+ const statusCategory = statusToCategory(status);
111
123
  if (Utils.isValidXml(error?.response?.data)) {
112
124
  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}}`);
125
+ childLogger.error({
126
+ statusCategory,
127
+ harmonyCode: parsedError?.code,
128
+ harmonyName: parsedError?.name,
129
+ harmonyMessage: parsedError?.message,
130
+ }, `Harmony request error`);
131
+ 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
132
  }
116
133
  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.`);
134
+ childLogger.error({ statusCategory }, `API request error due to response not in Harmony XML format`);
135
+ return new RequestError(`API request failed but error response is not in Harmony XML format. Please check the logs for more details.`, { status });
119
136
  }
120
137
  }
121
138
  else if (error?.request) {
139
+ childLogger.error({ statusCategory: "no_response" }, `No response received from Harmony API`);
122
140
  return new RequestError("No response received from the server during API call");
123
141
  }
124
142
  else {
143
+ childLogger.error({ statusCategory: "setup_failure" }, `Harmony API request setup failed`);
125
144
  return new RequestError(`API request setup failed: ${error?.message}`);
126
145
  }
127
146
  }
128
- logger.error(error, `⛔️ Unexpected error occurred during API request`);
147
+ logger.error({ err: error, statusCategory: "unexpected" }, `⛔️ Unexpected error occurred during API request`);
129
148
  return new RequestError(`Unexpected error occurred during API request`);
130
149
  }
131
150
  /**
@@ -161,6 +180,15 @@ export class ApiHelper {
161
180
  };
162
181
  }
163
182
  }
183
+ function statusToCategory(status) {
184
+ if (status === undefined)
185
+ return "unknown";
186
+ if (status >= 400 && status < 500)
187
+ return "client_error";
188
+ if (status >= 500 && status < 600)
189
+ return "server_error";
190
+ return "unknown";
191
+ }
164
192
  /**
165
193
  * Check whether the provided parameters meet the specified limits. If any parameter exceeds the limit, an error will be thrown.
166
194
  * 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 { AuthenticationError, 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,156 @@ 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("returns an already-parsed HarmonyAPIError unchanged (no re-masking) [DAT-2135]", async () => {
239
+ // A module-level catch may re-parse an error that sendSoapRequest already
240
+ // turned into a RequestError carrying status/code. Re-parsing must NOT
241
+ // discard those — otherwise the real fault (e.g. a 500 "Invalid Session")
242
+ // is masked behind the generic "Unexpected error" message and never
243
+ // reaches the caller.
244
+ const alreadyParsed = new RequestError(`API request failed with status 500: {code: S:Server, name: Invalid Session., message: }`, { status: 500, code: "S:Server" });
245
+ const result = await ApiHelper.parseError(alreadyParsed);
246
+ expect(result).toBe(alreadyParsed);
247
+ expect(result.status).toBe(500);
248
+ expect(result.code).toBe("S:Server");
249
+ expect(result.message).toContain("Invalid Session.");
250
+ });
251
+ it("does not downgrade an already-parsed AuthenticationError [DAT-2135]", async () => {
252
+ // AuthenticationError is the other HarmonyAPIError subclass; a 401 session
253
+ // drop must keep its type and status, not be re-parsed into a RequestError.
254
+ const authErr = new AuthenticationError("Invalid Session.", {
255
+ status: 401,
256
+ code: "S:Server",
257
+ });
258
+ const result = await ApiHelper.parseError(authErr);
259
+ expect(result).toBe(authErr);
260
+ expect(result).toBeInstanceOf(AuthenticationError);
261
+ expect(result.status).toBe(401);
262
+ });
263
+ it("preserves gift-card serial-number substring in message (backward-compat)", async () => {
264
+ const err = makeAxiosError({
265
+ response: { status: 400, data: GIFT_CARD_SERIAL_FAULT },
266
+ });
267
+ const result = await ApiHelper.parseError(err);
268
+ // core-v2 services/gift-cards/strategies/harmony.ts:158 does
269
+ // err.message.includes("is not a valid Serial Number value") — must remain true.
270
+ expect(result.message.includes("is not a valid Serial Number value")).toBe(true);
271
+ });
272
+ it("logs structured fields for 4xx XML (statusCategory=client_error + harmonyCode)", async () => {
273
+ const err = makeAxiosError({
274
+ response: { status: 400, data: HARMONY_XML_FAULT },
275
+ });
276
+ await ApiHelper.parseError(err);
277
+ expect(childErrorSpy).toHaveBeenCalledTimes(1);
278
+ const [ctx, msg] = childErrorSpy.mock.calls[0];
279
+ expect(ctx).toMatchObject({
280
+ statusCategory: "client_error",
281
+ harmonyCode: "S:Server",
282
+ harmonyName: "Failed Test Request",
283
+ harmonyMessage: "[204-104-2408071537-0000851206] (Invalid value)",
284
+ });
285
+ expect(msg).toBe("Harmony request error");
286
+ });
287
+ it("logs statusCategory=server_error for 5xx", async () => {
288
+ const err = makeAxiosError({
289
+ response: { status: 500, data: HARMONY_XML_FAULT },
290
+ });
291
+ await ApiHelper.parseError(err);
292
+ const [ctx] = childErrorSpy.mock.calls[0];
293
+ expect(ctx).toMatchObject({ statusCategory: "server_error" });
294
+ });
295
+ it("logs statusCategory=no_response when request never got a response", async () => {
296
+ const err = makeAxiosError({ request: {} });
297
+ await ApiHelper.parseError(err);
298
+ const [ctx, msg] = childErrorSpy.mock.calls[0];
299
+ expect(ctx).toMatchObject({ statusCategory: "no_response" });
300
+ expect(msg).toBe("No response received from Harmony API");
301
+ });
302
+ });
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.1",
4
4
  "description": "Harmony API SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",