@dotdev/harmony-sdk 1.30.1 → 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
+ });
@@ -102,6 +102,7 @@ export function mapStock(src) {
102
102
  sellEndDate: (src?.sell_end_date ?? [])[0],
103
103
  deliverFromDate: (src?.deliver_from_date ?? [])[0],
104
104
  isoCountryOfOrigin: (src?.iso_country_of_origin ?? [])[0],
105
+ tariffCode: (src?.tariff_code ?? [])[0],
105
106
  stockActive: (src?.stock_active ?? [])[0],
106
107
  alternateNamekey: (src?.alternate_namekey ?? [])[0],
107
108
  size: (src?.size ?? [])[0],
@@ -367,6 +367,7 @@ describe("StockLookup", () => {
367
367
  <sell_end_date></sell_end_date>
368
368
  <deliver_from_date></deliver_from_date>
369
369
  <iso_country_of_origin></iso_country_of_origin>
370
+ <tariff_code>6204.33.00</tariff_code>
370
371
  <size>6</size>
371
372
  <pack></pack>
372
373
  </Stock>
@@ -425,6 +426,7 @@ describe("StockLookup", () => {
425
426
  sellEndDate: "",
426
427
  deliverFromDate: "",
427
428
  isoCountryOfOrigin: "",
429
+ tariffCode: "6204.33.00",
428
430
  size: "6",
429
431
  pack: "",
430
432
  stockActive: undefined,
@@ -181,6 +181,7 @@ export interface Stock {
181
181
  sellEndDate: string;
182
182
  deliverFromDate: string;
183
183
  isoCountryOfOrigin: string;
184
+ tariffCode: string;
184
185
  size?: string;
185
186
  pack?: string;
186
187
  stockActive?: string;
@@ -232,6 +233,7 @@ export interface StockRaw {
232
233
  sell_end_date: string[];
233
234
  deliver_from_date: string[];
234
235
  iso_country_of_origin: string[];
236
+ tariff_code: string[];
235
237
  size: string[];
236
238
  pack: string[];
237
239
  stock_active: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdev/harmony-sdk",
3
- "version": "1.30.1",
3
+ "version": "1.32.0",
4
4
  "description": "Harmony API SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",