@open-xchange/soap-client 0.1.2 → 0.1.4

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,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.4] - 2026-05-21
8
+
9
+ ### Fixed
10
+
11
+ - Retry log lines no longer print `(undefined)` when SOAP errors come wrapped in `{ error: ... }` envelopes
12
+ - Preserve `faultstring`, `faultcode`, `detail`, `root`, `response`, `body` on thrown errors so consumers can pattern-match on the SOAP fault structure
13
+ - Unwrap `original_error` (snake_case) from `PropagatedError` chains in addition to `originalError` / `error`
14
+
15
+ ## [0.1.3] - 2026-04-28
16
+
17
+ ### Fixed
18
+
19
+ - Use default admin credentials for `convertUserToSharedAccount` SOAP call
20
+
7
21
  ## [0.1.2] - 2026-04-21
8
22
 
9
23
  ### Added
@@ -35,7 +49,7 @@ All notable changes to this project will be documented in this file.
35
49
 
36
50
  - Exponential backoff with jitter to SOAP client retries
37
51
 
38
- ## [0.0.9] - 2025-12-12
52
+ ## 0.0.9 - 2025-12-12
39
53
 
40
54
  ### Changed
41
55
 
@@ -83,8 +97,24 @@ All notable changes to this project will be documented in this file.
83
97
 
84
98
  - Update dependencies
85
99
 
86
- ## [0.0.1] - 2025-02-28
100
+ ## 0.0.1 - 2025-02-28
87
101
 
88
102
  ### Added
89
103
 
90
104
  - Initial release: extract SOAP client into its own library
105
+
106
+ [unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.4...main
107
+ [0.1.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.3...soap-client-0.1.4
108
+ [0.1.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.2...soap-client-0.1.3
109
+ [0.1.2]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.1...soap-client-0.1.2
110
+ [0.1.0]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.12...soap-client-0.1.0
111
+ [0.0.12]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.11...soap-client-0.0.12
112
+ [0.0.11]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.10...soap-client-0.0.11
113
+ [0.0.10]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.8...soap-client-0.0.10
114
+ [0.0.8]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.7...soap-client-0.0.8
115
+ [0.0.7]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.6...soap-client-0.0.7
116
+ [0.0.6]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.5...soap-client-0.0.6
117
+ [0.0.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.4...soap-client-0.0.5
118
+ [0.0.4]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.3...soap-client-0.0.4
119
+ [0.0.3]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.0.2...soap-client-0.0.3
120
+ [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.2",
3
+ "version": "0.1.4",
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.1"
26
26
  },
27
27
  "devDependencies": {
28
+ "vitest": "^4.1.5",
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
  }
@@ -56,7 +56,7 @@ export async function convertUserToSharedAccount (context, userId, password) {
56
56
  ctx: { id: context.id },
57
57
  user: { id: userId },
58
58
  password,
59
- auth: context.auth
59
+ auth: context.admin
60
60
  })
61
61
  }
62
62
 
package/soap.js CHANGED
@@ -32,14 +32,59 @@ for (const envFile of ['.env', '.env.defaults']) {
32
32
  // This flag enables debug output including timing information.
33
33
  const debug = process.env.DEBUG_SOAP === 'true'
34
34
 
35
+ /**
36
+ * Walk one layer of common error-wrapper shapes:
37
+ * - `{ originalError }` — soap-client retry/catch chain
38
+ * - `{ error }` — soap@1.9.x http handler envelope
39
+ * - `{ original_error }` — appsuite-codeceptjs `PropagatedError` (snake_case)
40
+ *
41
+ * Returns the inner value, or the input unchanged if it doesn't match any shape.
42
+ *
43
+ * @param {any} e
44
+ * @returns {any}
45
+ */
46
+ function unwrapError (e) {
47
+ return e?.originalError ?? e?.error ?? e?.original_error ?? e
48
+ }
49
+
50
+ /**
51
+ * Unwrap common SOAP error shapes and produce a useful one-line description.
52
+ *
53
+ * The `soap` library and various intermediate layers throw errors in
54
+ * inconsistent shapes — sometimes a real Error with `.message`, sometimes a
55
+ * plain object whose Error lives under `.originalError`, `.error`, or
56
+ * `.original_error`, and sometimes the raw SOAP fault object (no `.message`,
57
+ * but the human-readable text is at `.root.Envelope.Body.Fault.faultstring`).
58
+ * Without unwrapping, `${err.message}` interpolates as `"undefined"` and a
59
+ * retry/failure log tells you nothing about why the call actually failed.
60
+ *
61
+ * Falls back through: unwrap → message → SOAP fault string → error code →
62
+ * JSON dump (truncated) → "unknown".
63
+ *
64
+ * @param {any} err The thrown value (real Error, wrapped object, or plain SOAP fault).
65
+ * @returns {string} A short human-readable description, never undefined.
66
+ */
67
+ function describeError (err) {
68
+ if (err == null) return 'unknown'
69
+ const e = unwrapError(err)
70
+ if (e == null) return 'unknown'
71
+ return e?.message ||
72
+ e?.root?.Envelope?.Body?.Fault?.faultstring ||
73
+ e?.code ||
74
+ (typeof e === 'string' ? e : null) ||
75
+ (() => { try { return JSON.stringify(e)?.slice(0, 200) } catch { return null } })() ||
76
+ 'unknown'
77
+ }
78
+
35
79
  // Handle specifically timeout-related unhandled promise rejections as fallback
36
80
  process.on('unhandledRejection', (reason, promise) => {
37
- if (reason?.code === 'ECONNABORTED' && (reason?.message?.includes('timeout') || reason?.message?.includes('exceeded'))) {
81
+ const r = unwrapError(reason)
82
+ if (r?.code === 'ECONNABORTED' && (r?.message?.includes('timeout') || r?.message?.includes('exceeded'))) {
38
83
  return
39
84
  }
40
85
 
41
86
  // Log other unhandled rejections as they indicate real problems
42
- console.error('Unhandled promise rejection:', reason?.message || reason)
87
+ console.error('Unhandled promise rejection:', describeError(reason))
43
88
  })
44
89
 
45
90
  // This URL is used to create the SOAP client.
@@ -71,7 +116,38 @@ const obs = new PerformanceObserver((items) => {
71
116
  obs.observe({ entryTypes: ['measure'] })
72
117
 
73
118
  async function logSoapError (e) {
74
- console.error(e?.originalError?.root?.Envelope?.Body?.Fault?.faultstring || e.message)
119
+ console.error(describeError(e))
120
+ }
121
+
122
+ /**
123
+ * If `e` carries a SOAP fault (after one level of wrapper unwrap), return a
124
+ * new Error whose `.message` is the friendly `"SOAP Fault: <faultstring>"` and
125
+ * whose structured fault fields are preserved as own properties. Otherwise
126
+ * return `e` unchanged.
127
+ *
128
+ * Why: the proxy catch used to throw `new Error("SOAP Fault: ...")` which
129
+ * stripped `.faultstring`, `.detail`, `.root`, `.response`, and `.body` — and
130
+ * downstream code in `appsuite-codeceptjs` pattern-matches on those exact
131
+ * fields (e.g. `err.faultstring` to ignore "No such user"). Preserving them
132
+ * keeps those consumer paths working while still giving operators a clean
133
+ * one-line message.
134
+ *
135
+ * @param {any} e
136
+ * @returns {any}
137
+ */
138
+ function wrapSoapFault (e) {
139
+ const unwrapped = unwrapError(e)
140
+ const fault = unwrapped?.root?.Envelope?.Body?.Fault
141
+ if (!fault) return e
142
+ const wrapped = new Error(`SOAP Fault: ${fault.faultstring}`)
143
+ return Object.assign(wrapped, {
144
+ faultstring: fault.faultstring,
145
+ faultcode: fault.faultcode,
146
+ detail: fault.detail,
147
+ root: unwrapped.root,
148
+ response: unwrapped.response,
149
+ body: unwrapped.body
150
+ })
75
151
  }
76
152
 
77
153
  /**
@@ -80,18 +156,21 @@ async function logSoapError (e) {
80
156
  * @returns {boolean} True if the error should abort the retry, false otherwise.
81
157
  **/
82
158
  function shouldAbortRetry (error) {
159
+ // Same unwrapping rules as describeError — the real SOAP fault may live
160
+ // one layer deep.
161
+ const err = unwrapError(error)
83
162
  try {
84
163
  // 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')) {
164
+ if (err?.code === 'ETIMEDOUT' || err?.code === 'ECONNRESET' || err?.code === 'ECONNABORTED' || err?.message?.includes('timeout')) {
86
165
  return false // Allow retry for timeout errors
87
166
  }
88
167
 
89
168
  // Handle cases where error doesn't have the expected structure
90
- if (!error?.root?.Envelope?.Body?.Fault) {
169
+ if (!err?.root?.Envelope?.Body?.Fault) {
91
170
  return false // Let it retry for unexpected error structures
92
171
  }
93
172
 
94
- const fault = error.root.Envelope.Body.Fault
173
+ const fault = err.root.Envelope.Body.Fault
95
174
  const details = fault.detail
96
175
  const blockedFaultStrings = [
97
176
  /Context \d+ already exists/,
@@ -173,7 +252,7 @@ async function createClientAsync (type) {
173
252
  randomize: true, // Add jitter to spread out concurrent retries
174
253
  onFailedAttempt: async error => {
175
254
  if (shouldAbortRetry(error)) throw new AbortError(error)
176
- console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${error.message})`)
255
+ console.log(`Retrying ${String(prop)} in ${error.retriesLeft} attempts (${describeError(error)})`)
177
256
  }
178
257
  })
179
258
 
@@ -183,11 +262,7 @@ async function createClientAsync (type) {
183
262
  if (!result || !result[0]) return
184
263
  return result[0]?.return
185
264
  } 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
265
+ throw wrapSoapFault(e)
191
266
  }
192
267
  }
193
268
  } else {
@@ -197,4 +272,4 @@ async function createClientAsync (type) {
197
272
  })
198
273
  }
199
274
 
200
- export { createClientAsync, logSoapError }
275
+ 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
+ })