@open-xchange/soap-client 0.1.3 → 0.1.5

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 CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.5] - 2026-05-26
8
+
9
+ ### Fixed
10
+
11
+ - Retry the WSDL fetch on transient network errors (ECONNRESET / ETIMEDOUT / etc.). The per-method `pRetry` wrapper was already handling SOAP method calls, but `SOAP.createClientAsync()` itself (which fetches the WSDL via axios under the hood) was unretried, so a single TLS hiccup during bootstrap would kill the whole test run.
12
+
13
+ ### Changed
14
+
15
+ - Retry policy tuned to ride out a recovery window of up to ~3 minutes per call: 10 retries (11 attempts total) with exponential backoff from 1s up to 30s, capped, with jitter. Previous 3-retry / 10s-cap budget was useful for one-packet blips but ran out before a recovering service could come back. The same policy now applies to both the WSDL fetch and per-method SOAP calls (factored into a shared constant). Permanent SOAP faults (already-exists, auth failure, etc.) still abort immediately via `shouldAbortRetry`.
16
+
17
+ ## [0.1.4] - 2026-05-21
18
+
19
+ ### Fixed
20
+
21
+ - Retry log lines no longer print `(undefined)` when SOAP errors come wrapped in `{ error: ... }` envelopes
22
+ - Preserve `faultstring`, `faultcode`, `detail`, `root`, `response`, `body` on thrown errors so consumers can pattern-match on the SOAP fault structure
23
+ - Unwrap `original_error` (snake_case) from `PropagatedError` chains in addition to `originalError` / `error`
24
+
7
25
  ## [0.1.3] - 2026-04-28
8
26
 
9
27
  ### Fixed
@@ -41,7 +59,7 @@ All notable changes to this project will be documented in this file.
41
59
 
42
60
  - Exponential backoff with jitter to SOAP client retries
43
61
 
44
- ## [0.0.9] - 2025-12-12
62
+ ## 0.0.9 - 2025-12-12
45
63
 
46
64
  ### Changed
47
65
 
@@ -89,8 +107,25 @@ All notable changes to this project will be documented in this file.
89
107
 
90
108
  - Update dependencies
91
109
 
92
- ## [0.0.1] - 2025-02-28
110
+ ## 0.0.1 - 2025-02-28
93
111
 
94
112
  ### Added
95
113
 
96
114
  - Initial release: extract SOAP client into its own library
115
+
116
+ [unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.5...main
117
+ [0.1.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.4...soap-client-0.1.5
118
+ [0.1.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.3...soap-client-0.1.4
119
+ [0.1.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.2...soap-client-0.1.3
120
+ [0.1.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.1...soap-client-0.1.2
121
+ [0.1.0]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.12...soap-client-0.1.0
122
+ [0.0.12]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.11...soap-client-0.0.12
123
+ [0.0.11]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.10...soap-client-0.0.11
124
+ [0.0.10]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.8...soap-client-0.0.10
125
+ [0.0.8]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.7...soap-client-0.0.8
126
+ [0.0.7]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.6...soap-client-0.0.7
127
+ [0.0.6]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.5...soap-client-0.0.6
128
+ [0.0.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.4...soap-client-0.0.5
129
+ [0.0.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.3...soap-client-0.0.4
130
+ [0.0.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.2...soap-client-0.0.3
131
+ [0.0.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/tags/soap-client-0.0.2
package/README.md CHANGED
@@ -15,7 +15,7 @@ This project provides an API facade for the OX App Suite Middleware SOAP API, ma
15
15
  You can use the built-in provisioning script to automate resource creation. Simply run:
16
16
 
17
17
  ```sh
18
- npx @open-xchange/soap-client
18
+ pnpm dlx @open-xchange/soap-client
19
19
  ```
20
20
  If you already have installed the package in your project, you can run the script directly:
21
21
 
@@ -26,7 +26,7 @@ pnpm provision.js
26
26
  This will execute the provisioning tool, which reads configuration from a JSON file (default: `./provisioning.json`). You can specify a different file with the `-f` option:
27
27
 
28
28
  ```sh
29
- npx @open-xchange/soap-client -f example-provisioning.json
29
+ pnpm dlx @open-xchange/soap-client -f example-provisioning.json
30
30
  ```
31
31
  The JSON file should contain the resources to provision. For example:
32
32
 
@@ -81,17 +81,17 @@ The JSON file should contain the resources to provision. For example:
81
81
  The CLI supports creating contexts, users, and secondary accounts:
82
82
 
83
83
  ```sh
84
- npx @open-xchange/soap-client create context --name my-context
85
- npx @open-xchange/soap-client create user --context-id 123 --email user@example.com --name myuser
86
- npx @open-xchange/soap-client create account --context-id 123 --name info --email info@example.com --users 1,2,3
84
+ pnpm dlx @open-xchange/soap-client create context --name my-context
85
+ pnpm dlx @open-xchange/soap-client create user --context-id 123 --email user@example.com --name myuser
86
+ pnpm dlx @open-xchange/soap-client create account --context-id 123 --name info --email info@example.com --users 1,2,3
87
87
  ```
88
88
 
89
89
  ### Example: Deleting Resources
90
90
 
91
91
  ```sh
92
- npx @open-xchange/soap-client delete context --id 123
93
- npx @open-xchange/soap-client delete user --context-id 123 --id 456
94
- npx @open-xchange/soap-client delete account --context-id 123 --email info@example.com --users 1,2,3
92
+ pnpm dlx @open-xchange/soap-client delete context --id 123
93
+ pnpm dlx @open-xchange/soap-client delete user --context-id 123 --id 456
94
+ pnpm dlx @open-xchange/soap-client delete account --context-id 123 --email info@example.com --users 1,2,3
95
95
  ```
96
96
 
97
97
  ## API Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-xchange/soap-client",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "SOAP client for OX App Suite",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -22,13 +22,15 @@
22
22
  "dependencies": {
23
23
  "commander": "^14.0.3",
24
24
  "p-retry": "^8.0.0",
25
- "soap": "^1.8.0"
25
+ "soap": "^1.9.3"
26
26
  },
27
27
  "devDependencies": {
28
+ "vitest": "^4.1.7",
28
29
  "@open-xchange/lint": "0.3.0"
29
30
  },
30
31
  "scripts": {
31
32
  "lint": "eslint .",
33
+ "test": "vitest --run",
32
34
  "provision": "node ./bin/provision.js"
33
35
  }
34
36
  }
package/soap.js CHANGED
@@ -25,6 +25,21 @@ import pRetry, { AbortError as RetryAbortError } from 'p-retry'
25
25
  // Set AbortError correctly
26
26
  const AbortError = RetryAbortError
27
27
 
28
+ // Shared retry policy for both the WSDL fetch and per-method SOAP calls.
29
+ // Designed to ride out a service recovery window of up to ~3 minutes:
30
+ // fast retries for the first ~30s catch transient blips cheaply, then the
31
+ // schedule settles into 30s polling so a recovering service is picked up
32
+ // within one poll. Note: each pRetry cycle is independent, so back-to-back
33
+ // failures (WSDL + method) can spend up to ~6 minutes total — keep the CI
34
+ // job timeout above that or recovery won't be observable.
35
+ const RETRY_OPTIONS = {
36
+ retries: 10, // 11 attempts total
37
+ factor: 2, // exponential growth, doubling each attempt
38
+ minTimeout: 1000, // first wait = 1s
39
+ maxTimeout: 30000, // cap at 30s once growth exceeds it
40
+ randomize: true // jitter to avoid thundering herd across parallel shards
41
+ }
42
+
28
43
  for (const envFile of ['.env', '.env.defaults']) {
29
44
  try { process.loadEnvFile(envFile) } catch {}
30
45
  }
@@ -32,14 +47,59 @@ for (const envFile of ['.env', '.env.defaults']) {
32
47
  // This flag enables debug output including timing information.
33
48
  const debug = process.env.DEBUG_SOAP === 'true'
34
49
 
50
+ /**
51
+ * Walk one layer of common error-wrapper shapes:
52
+ * - `{ originalError }` — soap-client retry/catch chain
53
+ * - `{ error }` — soap@1.9.x http handler envelope
54
+ * - `{ original_error }` — appsuite-codeceptjs `PropagatedError` (snake_case)
55
+ *
56
+ * Returns the inner value, or the input unchanged if it doesn't match any shape.
57
+ *
58
+ * @param {any} e
59
+ * @returns {any}
60
+ */
61
+ function unwrapError (e) {
62
+ return e?.originalError ?? e?.error ?? e?.original_error ?? e
63
+ }
64
+
65
+ /**
66
+ * Unwrap common SOAP error shapes and produce a useful one-line description.
67
+ *
68
+ * The `soap` library and various intermediate layers throw errors in
69
+ * inconsistent shapes — sometimes a real Error with `.message`, sometimes a
70
+ * plain object whose Error lives under `.originalError`, `.error`, or
71
+ * `.original_error`, and sometimes the raw SOAP fault object (no `.message`,
72
+ * but the human-readable text is at `.root.Envelope.Body.Fault.faultstring`).
73
+ * Without unwrapping, `${err.message}` interpolates as `"undefined"` and a
74
+ * retry/failure log tells you nothing about why the call actually failed.
75
+ *
76
+ * Falls back through: unwrap → message → SOAP fault string → error code →
77
+ * JSON dump (truncated) → "unknown".
78
+ *
79
+ * @param {any} err The thrown value (real Error, wrapped object, or plain SOAP fault).
80
+ * @returns {string} A short human-readable description, never undefined.
81
+ */
82
+ function describeError (err) {
83
+ if (err == null) return 'unknown'
84
+ const e = unwrapError(err)
85
+ if (e == null) return 'unknown'
86
+ return e?.message ||
87
+ e?.root?.Envelope?.Body?.Fault?.faultstring ||
88
+ e?.code ||
89
+ (typeof e === 'string' ? e : null) ||
90
+ (() => { try { return JSON.stringify(e)?.slice(0, 200) } catch { return null } })() ||
91
+ 'unknown'
92
+ }
93
+
35
94
  // Handle specifically timeout-related unhandled promise rejections as fallback
36
95
  process.on('unhandledRejection', (reason, promise) => {
37
- if (reason?.code === 'ECONNABORTED' && (reason?.message?.includes('timeout') || reason?.message?.includes('exceeded'))) {
96
+ const r = unwrapError(reason)
97
+ if (r?.code === 'ECONNABORTED' && (r?.message?.includes('timeout') || r?.message?.includes('exceeded'))) {
38
98
  return
39
99
  }
40
100
 
41
101
  // Log other unhandled rejections as they indicate real problems
42
- console.error('Unhandled promise rejection:', reason?.message || reason)
102
+ console.error('Unhandled promise rejection:', describeError(reason))
43
103
  })
44
104
 
45
105
  // This URL is used to create the SOAP client.
@@ -71,7 +131,38 @@ const obs = new PerformanceObserver((items) => {
71
131
  obs.observe({ entryTypes: ['measure'] })
72
132
 
73
133
  async function logSoapError (e) {
74
- console.error(e?.originalError?.root?.Envelope?.Body?.Fault?.faultstring || e.message)
134
+ console.error(describeError(e))
135
+ }
136
+
137
+ /**
138
+ * If `e` carries a SOAP fault (after one level of wrapper unwrap), return a
139
+ * new Error whose `.message` is the friendly `"SOAP Fault: <faultstring>"` and
140
+ * whose structured fault fields are preserved as own properties. Otherwise
141
+ * return `e` unchanged.
142
+ *
143
+ * Why: the proxy catch used to throw `new Error("SOAP Fault: ...")` which
144
+ * stripped `.faultstring`, `.detail`, `.root`, `.response`, and `.body` — and
145
+ * downstream code in `appsuite-codeceptjs` pattern-matches on those exact
146
+ * fields (e.g. `err.faultstring` to ignore "No such user"). Preserving them
147
+ * keeps those consumer paths working while still giving operators a clean
148
+ * one-line message.
149
+ *
150
+ * @param {any} e
151
+ * @returns {any}
152
+ */
153
+ function wrapSoapFault (e) {
154
+ const unwrapped = unwrapError(e)
155
+ const fault = unwrapped?.root?.Envelope?.Body?.Fault
156
+ if (!fault) return e
157
+ const wrapped = new Error(`SOAP Fault: ${fault.faultstring}`)
158
+ return Object.assign(wrapped, {
159
+ faultstring: fault.faultstring,
160
+ faultcode: fault.faultcode,
161
+ detail: fault.detail,
162
+ root: unwrapped.root,
163
+ response: unwrapped.response,
164
+ body: unwrapped.body
165
+ })
75
166
  }
76
167
 
77
168
  /**
@@ -80,18 +171,21 @@ async function logSoapError (e) {
80
171
  * @returns {boolean} True if the error should abort the retry, false otherwise.
81
172
  **/
82
173
  function shouldAbortRetry (error) {
174
+ // Same unwrapping rules as describeError — the real SOAP fault may live
175
+ // one layer deep.
176
+ const err = unwrapError(error)
83
177
  try {
84
178
  // Handle timeout errors - these should NOT abort retry, let them retry
85
- if (error?.code === 'ETIMEDOUT' || error?.code === 'ECONNRESET' || error?.code === 'ECONNABORTED' || error?.message?.includes('timeout')) {
179
+ if (err?.code === 'ETIMEDOUT' || err?.code === 'ECONNRESET' || err?.code === 'ECONNABORTED' || err?.message?.includes('timeout')) {
86
180
  return false // Allow retry for timeout errors
87
181
  }
88
182
 
89
183
  // Handle cases where error doesn't have the expected structure
90
- if (!error?.root?.Envelope?.Body?.Fault) {
184
+ if (!err?.root?.Envelope?.Body?.Fault) {
91
185
  return false // Let it retry for unexpected error structures
92
186
  }
93
187
 
94
- const fault = error.root.Envelope.Body.Fault
188
+ const fault = err.root.Envelope.Body.Fault
95
189
  const details = fault.detail
96
190
  const blockedFaultStrings = [
97
191
  /Context \d+ already exists/,
@@ -134,13 +228,23 @@ async function createClientAsync (type) {
134
228
  performance.mark(startMark)
135
229
  const endpoint = `${provisioningUrl}/webservices/${type}`
136
230
  const url = `${endpoint}/?wsdl`
137
- const client = await SOAP.createClientAsync(url, {
231
+ // The WSDL fetch itself can hit transient network errors (ECONNRESET,
232
+ // ETIMEDOUT, ...). Without retry here, a single TLS hiccup during
233
+ // bootstrap kills the whole job before any per-method pRetry below has
234
+ // a chance to run.
235
+ const client = await pRetry(() => SOAP.createClientAsync(url, {
138
236
  endpoint,
139
237
  suppressStack: true,
140
238
  wsdl_options: {
141
239
  forever: true
142
240
  },
143
241
  gzip: true
242
+ }), {
243
+ ...RETRY_OPTIONS,
244
+ onFailedAttempt: async error => {
245
+ if (shouldAbortRetry(error)) throw new AbortError(error)
246
+ console.log(`Retrying WSDL fetch (${type}) in ${error.retriesLeft} attempts (${describeError(error)})`)
247
+ }
144
248
  })
145
249
 
146
250
  // https://stackoverflow.com/questions/30740415/namespace-for-array-field-in-node-soap-client-node-js
@@ -167,13 +271,10 @@ async function createClientAsync (type) {
167
271
 
168
272
  try {
169
273
  const result = await pRetry(() => origMethod.apply(this, [soapOptions, { timeout: 30000, ...clientOptions }, ...args]), {
170
- retries: 3,
171
- minTimeout: 1000, // Start with 1 second delay between retries
172
- maxTimeout: 10000, // Cap retry delay at 10 seconds
173
- randomize: true, // Add jitter to spread out concurrent retries
274
+ ...RETRY_OPTIONS,
174
275
  onFailedAttempt: async error => {
175
276
  if (shouldAbortRetry(error)) throw new AbortError(error)
176
- console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${error.message})`)
277
+ console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${describeError(error)})`)
177
278
  }
178
279
  })
179
280
 
@@ -183,11 +284,7 @@ async function createClientAsync (type) {
183
284
  if (!result || !result[0]) return
184
285
  return result[0]?.return
185
286
  } catch (e) {
186
- const soapError = e?.originalError?.root?.Envelope?.Body?.Fault
187
- if (soapError) {
188
- throw new Error(`SOAP Fault: ${soapError.faultstring}`)
189
- }
190
- throw e
287
+ throw wrapSoapFault(e)
191
288
  }
192
289
  }
193
290
  } else {
@@ -197,4 +294,4 @@ async function createClientAsync (type) {
197
294
  })
198
295
  }
199
296
 
200
- export { createClientAsync, logSoapError }
297
+ export { createClientAsync, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com>
3
+ * @license AGPL-3.0
4
+ */
5
+
6
+ // soap.js reads PROVISIONING_URL at module load to build the SOAP endpoint URL.
7
+ // We don't exercise that path here; set a stub so the import doesn't crash.
8
+ process.env.PROVISIONING_URL = 'http://soap.test/'
9
+
10
+ const { describeError, shouldAbortRetry, wrapSoapFault } = await import('../soap.js')
11
+
12
+ // Build a SOAP fault object matching the real shape (from soap@1.9.x).
13
+ function soapFault (faultstring, detail = {}) {
14
+ return {
15
+ root: { Envelope: { Body: { Fault: { faultstring, faultcode: 'soap:Server', detail } } } },
16
+ response: { status: 500, statusText: 'Internal Server Error' },
17
+ body: '<soap:Envelope>...</soap:Envelope>'
18
+ }
19
+ }
20
+
21
+ // Build the wrapped shape that crashed the retry log: { error: <Error w/ SOAP fault props> }
22
+ function wrappedFault (faultstring, detail = {}) {
23
+ const err = new Error(`soap:Server: ${faultstring}`)
24
+ Object.assign(err, soapFault(faultstring, detail))
25
+ return { error: err }
26
+ }
27
+
28
+ describe('describeError', () => {
29
+ it('returns the message of a plain Error', () => {
30
+ expect(describeError(new Error('boom'))).toBe('boom')
31
+ })
32
+
33
+ it('unwraps { originalError: <Error> } and returns the inner message', () => {
34
+ const inner = new Error('inner-msg')
35
+ expect(describeError({ originalError: inner })).toBe('inner-msg')
36
+ })
37
+
38
+ it('unwraps { error: <Error> } (the shape that produced "(undefined)" in retry logs)', () => {
39
+ const inner = new Error('inner-msg')
40
+ expect(describeError({ error: inner })).toBe('inner-msg')
41
+ })
42
+
43
+ it('falls back to the SOAP faultstring when the wrapped Error has none', () => {
44
+ // Real-world: soap@1.9.x sometimes throws an Error whose .message lives
45
+ // *inside* the SOAP fault rather than on the Error itself. Verify the
46
+ // faultstring path kicks in.
47
+ const fault = soapFault('Could not acquire claim for context 2572225')
48
+ // strip the message so the .root.Envelope... path is the only signal
49
+ const e = Object.assign(Object.create(Error.prototype), fault)
50
+ expect(describeError(e)).toBe('Could not acquire claim for context 2572225')
51
+ })
52
+
53
+ it('falls back to the SOAP faultstring through one level of { error: ... } wrapping', () => {
54
+ const wrapped = wrappedFault('Context 99 already exists')
55
+ // Stripping the inner message forces the faultstring fallback.
56
+ delete wrapped.error.message
57
+ expect(describeError(wrapped)).toBe('Context 99 already exists')
58
+ })
59
+
60
+ it('returns the error code for timeout-shaped errors (no message, no fault)', () => {
61
+ const e = { code: 'ETIMEDOUT' }
62
+ expect(describeError(e)).toBe('ETIMEDOUT')
63
+ })
64
+
65
+ it('returns a stringified preview if nothing else matches', () => {
66
+ const e = { weird: 'shape', no: 'normal fields' }
67
+ const out = describeError(e)
68
+ expect(out).toMatch(/weird/)
69
+ expect(out.length).toBeLessThanOrEqual(200)
70
+ })
71
+
72
+ it('returns "unknown" rather than the literal string "undefined" for empty input', () => {
73
+ expect(describeError(undefined)).toBe('unknown')
74
+ expect(describeError(null)).toBe('unknown')
75
+ })
76
+
77
+ it('handles strings thrown directly', () => {
78
+ expect(describeError('plain string error')).toBe('plain string error')
79
+ })
80
+
81
+ it('unwraps { original_error: <Error> } (snake_case from PropagatedError chains)', () => {
82
+ const inner = new Error('inner-msg')
83
+ expect(describeError({ original_error: inner })).toBe('inner-msg')
84
+ })
85
+
86
+ it('falls back to the SOAP faultstring through one level of { original_error: ... } wrapping', () => {
87
+ const inner = Object.assign(Object.create(Error.prototype), soapFault('Context 7 already exists'))
88
+ expect(describeError({ original_error: inner })).toBe('Context 7 already exists')
89
+ })
90
+ })
91
+
92
+ describe('shouldAbortRetry', () => {
93
+ it('does NOT abort on ETIMEDOUT / ECONNRESET / ECONNABORTED — these are retryable', () => {
94
+ expect(shouldAbortRetry({ code: 'ETIMEDOUT' })).toBe(false)
95
+ expect(shouldAbortRetry({ code: 'ECONNRESET' })).toBe(false)
96
+ expect(shouldAbortRetry({ code: 'ECONNABORTED' })).toBe(false)
97
+ })
98
+
99
+ it('does NOT abort on transient SOAP faults (e.g. context-claim conflict) — they retry until the lock frees', () => {
100
+ // This is the actual fault from the user's report — we *want* the retry
101
+ // loop to keep trying until the conflicting writer commits.
102
+ const e = soapFault('Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.')
103
+ expect(shouldAbortRetry(e)).toBe(false)
104
+ })
105
+
106
+ it('aborts on "Context N already exists" — re-running will keep failing', () => {
107
+ const e = soapFault('Context 4242 already exists')
108
+ expect(shouldAbortRetry(e)).toBe(true)
109
+ })
110
+
111
+ it('aborts on "Authentication failed"', () => {
112
+ const e = soapFault('Authentication failed')
113
+ expect(shouldAbortRetry(e)).toBe(true)
114
+ })
115
+
116
+ it('aborts on blocked exceptions in the fault detail (e.g. ContextExistsException)', () => {
117
+ const e = soapFault('some message', { ContextExistsException: {} })
118
+ expect(shouldAbortRetry(e)).toBe(true)
119
+ })
120
+
121
+ it('looks through one level of { error: ... } wrapping when probing the fault', () => {
122
+ const wrapped = wrappedFault('Context 4242 already exists')
123
+ expect(shouldAbortRetry(wrapped)).toBe(true)
124
+ })
125
+
126
+ it('looks through { originalError: ... } wrapping too', () => {
127
+ const inner = new Error('whatever')
128
+ Object.assign(inner, soapFault('Authentication failed'))
129
+ expect(shouldAbortRetry({ originalError: inner })).toBe(true)
130
+ })
131
+
132
+ it('looks through { original_error: ... } (snake_case) wrapping too', () => {
133
+ const inner = new Error('whatever')
134
+ Object.assign(inner, soapFault('Context 4242 already exists'))
135
+ expect(shouldAbortRetry({ original_error: inner })).toBe(true)
136
+ })
137
+
138
+ it('does NOT abort on completely unknown error structures — retry and let it sort itself out', () => {
139
+ expect(shouldAbortRetry({ random: 'object' })).toBe(false)
140
+ expect(shouldAbortRetry(undefined)).toBe(false)
141
+ expect(shouldAbortRetry(null)).toBe(false)
142
+ })
143
+ })
144
+
145
+ describe('wrapSoapFault', () => {
146
+ it('returns the input unchanged when no SOAP fault is present', () => {
147
+ const e = new Error('plain timeout')
148
+ expect(wrapSoapFault(e)).toBe(e)
149
+ })
150
+
151
+ it('returns the input unchanged for non-SOAP wrapped errors (e.g. axios timeout)', () => {
152
+ const e = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
153
+ expect(wrapSoapFault({ error: e })).toEqual({ error: e })
154
+ })
155
+
156
+ it('returns a NEW Error with the fault structure preserved on it', () => {
157
+ // Real-world: the soap library hands us an Error whose .root/.response/.body
158
+ // carry the structured fault. The proxy catch used to throw a plain new
159
+ // Error("SOAP Fault: …"), which DELETED the structured fields and broke
160
+ // downstream consumers that pattern-match on err.faultstring.
161
+ const inner = Object.assign(new Error('original'), soapFault('Context 42 already exists', { ContextExistsException: {} }))
162
+ const out = wrapSoapFault(inner)
163
+ expect(out).not.toBe(inner) // it's a new Error so the message can be friendly
164
+ expect(out).toBeInstanceOf(Error)
165
+ expect(out.message).toBe('SOAP Fault: Context 42 already exists')
166
+ expect(out.faultstring).toBe('Context 42 already exists')
167
+ expect(out.faultcode).toBe('soap:Server')
168
+ expect(out.detail).toEqual({ ContextExistsException: {} })
169
+ expect(out.root).toBe(inner.root)
170
+ expect(out.response).toBe(inner.response)
171
+ expect(out.body).toBe(inner.body)
172
+ })
173
+
174
+ it('unwraps { error: <Error w/ fault> } before extracting the fault', () => {
175
+ const inner = Object.assign(new Error('original'), soapFault('Authentication failed'))
176
+ const out = wrapSoapFault({ error: inner })
177
+ expect(out.faultstring).toBe('Authentication failed')
178
+ expect(out.message).toBe('SOAP Fault: Authentication failed')
179
+ })
180
+
181
+ it('unwraps { original_error: <Error w/ fault> } (snake_case) before extracting the fault', () => {
182
+ const inner = Object.assign(new Error('original'), soapFault('No such user'))
183
+ const out = wrapSoapFault({ original_error: inner })
184
+ expect(out.faultstring).toBe('No such user')
185
+ })
186
+ })
187
+
188
+ // Regression: the exact shape from the original incident report — a transient
189
+ // "Could not acquire claim" fault wrapped in soap@1.9.x's `{ error: <Error> }`
190
+ // envelope, with the StorageException nested in detail. Three concerns checked
191
+ // together so the next person debugging this flow can see the whole pipeline
192
+ // pass against the real symptom in one place.
193
+ describe('real-world: transient "Could not acquire claim" fault wrapped in { error: ... }', () => {
194
+ const faultstring = 'Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.; exceptionId -1132283668-9930'
195
+ function realWorldThrownValue () {
196
+ const err = new Error(`soap:Server: ${faultstring}`)
197
+ Object.assign(err, soapFault(faultstring, { StorageException: { StorageException: null } }))
198
+ return { error: err }
199
+ }
200
+
201
+ it('describeError produces a useful retry-log line (not "(undefined)")', () => {
202
+ const out = describeError(realWorldThrownValue())
203
+ expect(out).toContain('Could not acquire claim for context 2572225')
204
+ expect(out).not.toBe('undefined')
205
+ expect(out).not.toBe('unknown')
206
+ })
207
+
208
+ it('shouldAbortRetry returns false — this is transient, keep retrying', () => {
209
+ // StorageException is NOT in the blockedExceptions list, and the
210
+ // faultstring doesn't match any blockedFaultStrings regex, so the retry
211
+ // loop must keep going until the conflicting writer commits.
212
+ expect(shouldAbortRetry(realWorldThrownValue())).toBe(false)
213
+ })
214
+
215
+ it('wrapSoapFault preserves .faultstring, .detail.StorageException, .root, .response, .body', () => {
216
+ const out = wrapSoapFault(realWorldThrownValue())
217
+ expect(out).toBeInstanceOf(Error)
218
+ expect(out.message).toMatch(/^SOAP Fault: Could not acquire claim for context 2572225/)
219
+ expect(out.faultstring).toContain('Could not acquire claim for context 2572225')
220
+ expect(out.faultcode).toBe('soap:Server')
221
+ expect(out.detail).toEqual({ StorageException: { StorageException: null } })
222
+ expect(out.root.Envelope.Body.Fault.faultstring).toContain('Could not acquire')
223
+ expect(out.response.status).toBe(500)
224
+ expect(out.body).toMatch(/soap:Envelope/)
225
+ })
226
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ include: ['test/**/*.test.*']
7
+ }
8
+ })