onboardconnect-agent 1.4.0 → 1.4.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.
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
2
+ import { createServer } from "http";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ // Redirect the agent logger to a writable temp dir BEFORE importing peoplesoft.js
6
+ // (transitively constructs the winston file logger). Mirrors binary.test.ts.
7
+ process.env["OC_LOG_DIR"] = join(tmpdir(), "oc-agent-test-logs");
8
+ let ocPeoplesoftCall;
9
+ beforeAll(async () => {
10
+ ({ ocPeoplesoftCall } = await import("./peoplesoft.js"));
11
+ });
12
+ let openServers = [];
13
+ function ocStartServer(handler) {
14
+ const recorded = [];
15
+ return new Promise((resolve) => {
16
+ const server = createServer((req, res) => {
17
+ const chunks = [];
18
+ req.on("data", (c) => chunks.push(c));
19
+ req.on("end", () => {
20
+ const rec = {
21
+ method: req.method ?? "",
22
+ url: req.url ?? "",
23
+ headers: req.headers,
24
+ body: Buffer.concat(chunks).toString("utf8"),
25
+ };
26
+ recorded.push(rec);
27
+ handler(req, res, rec);
28
+ });
29
+ });
30
+ openServers.push(server);
31
+ server.listen(0, "127.0.0.1", () => {
32
+ const { port } = server.address();
33
+ resolve({ url: `http://127.0.0.1:${port}`, recorded });
34
+ });
35
+ });
36
+ }
37
+ // Await closes so no listener handle leaks (binary.test.ts teardown note).
38
+ async function ocCloseServers() {
39
+ await Promise.all(openServers.map((s) => new Promise((resolve) => s.close(() => resolve()))));
40
+ openServers = [];
41
+ }
42
+ afterEach(ocCloseServers);
43
+ afterAll(ocCloseServers);
44
+ let profileSeq = 0;
45
+ function ocPsProfile(baseUrl, over = {}) {
46
+ return {
47
+ id: `ps-${++profileSeq}`,
48
+ vendor: "peoplesoft",
49
+ baseUrl,
50
+ authType: "basic",
51
+ username: "isuser",
52
+ password: "isupass",
53
+ ...over,
54
+ };
55
+ }
56
+ function payload(over = {}) {
57
+ return {
58
+ profileId: "ps-x",
59
+ vendor: "peoplesoft",
60
+ operation: "test",
61
+ resource: "OC_PING",
62
+ ...over,
63
+ };
64
+ }
65
+ // Expected Basic header for the default credentials.
66
+ const BASIC = "Basic " + Buffer.from("isuser:isupass").toString("base64");
67
+ describe("ocPeoplesoftCall — test operation", () => {
68
+ it("probes the resource path with a Basic-auth header and resolves", async () => {
69
+ const { url, recorded } = await ocStartServer((_req, res) => {
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify({ ok: true }));
72
+ });
73
+ const profile = ocPsProfile(url);
74
+ const result = await ocPeoplesoftCall({
75
+ payload: payload({ profileId: profile.id, resource: "OC_PING" }),
76
+ profile,
77
+ });
78
+ expect(result.raw).toEqual({ ok: true, vendor: "peoplesoft", resource: "OC_PING" });
79
+ const probe = recorded[0];
80
+ expect(probe.method).toBe("GET");
81
+ expect(probe.url).toBe("/OC_PING");
82
+ expect(probe.headers["authorization"]).toBe(BASIC);
83
+ });
84
+ it("sends a Bearer header when authType is bearer", async () => {
85
+ const { url, recorded } = await ocStartServer((_req, res) => {
86
+ res.writeHead(200, { "Content-Type": "application/json" });
87
+ res.end("{}");
88
+ });
89
+ const profile = ocPsProfile(url, { authType: "bearer", bearerToken: "tok-123" });
90
+ await ocPeoplesoftCall({
91
+ payload: payload({ profileId: profile.id, resource: "OC_PING" }),
92
+ profile,
93
+ });
94
+ expect(recorded[0].headers["authorization"]).toBe("Bearer tok-123");
95
+ });
96
+ it("throws including the status when the probe fails", async () => {
97
+ const { url } = await ocStartServer((_req, res) => {
98
+ res.writeHead(401);
99
+ res.end("denied");
100
+ });
101
+ const profile = ocPsProfile(url);
102
+ await expect(ocPeoplesoftCall({
103
+ payload: payload({ profileId: profile.id, resource: "OC_PING" }),
104
+ profile,
105
+ })).rejects.toThrow(/PeopleSoft test probe failed \(401\)/);
106
+ });
107
+ it("throws when basic auth profile is missing username/password", async () => {
108
+ const profile = ocPsProfile("http://127.0.0.1:1", { username: "" });
109
+ await expect(ocPeoplesoftCall({
110
+ payload: payload({ profileId: profile.id, resource: "OC_PING" }),
111
+ profile,
112
+ })).rejects.toThrow(/missing username\/password/i);
113
+ });
114
+ });
115
+ describe("ocPeoplesoftCall — upsert", () => {
116
+ it("POSTs to the service-op path when no match key is present (create)", async () => {
117
+ const { url, recorded } = await ocStartServer((_req, res) => {
118
+ res.writeHead(200, { "Content-Type": "application/json" });
119
+ res.end(JSON.stringify({ EMPLID: "E999" }));
120
+ });
121
+ const profile = ocPsProfile(url);
122
+ const result = await ocPeoplesoftCall({
123
+ payload: payload({
124
+ profileId: profile.id,
125
+ operation: "upsert",
126
+ resource: "OC_CONSTITUENT",
127
+ record: { firstName: "Ann" },
128
+ }),
129
+ profile,
130
+ });
131
+ // Service op echoes EMPLID → that surfaces as sisId.
132
+ expect(result.sisId).toBe("E999");
133
+ const post = recorded[0];
134
+ expect(post.method).toBe("POST");
135
+ expect(post.url).toBe("/OC_CONSTITUENT");
136
+ expect(post.headers["content-type"]).toMatch(/application\/json/);
137
+ expect(JSON.parse(post.body)).toEqual({ firstName: "Ann" });
138
+ });
139
+ it("PUTs to the keyed instance path when a match key is present (update)", async () => {
140
+ const { url, recorded } = await ocStartServer((_req, res) => {
141
+ res.writeHead(200, { "Content-Type": "application/json" });
142
+ res.end("{}");
143
+ });
144
+ const profile = ocPsProfile(url);
145
+ const result = await ocPeoplesoftCall({
146
+ payload: payload({
147
+ profileId: profile.id,
148
+ operation: "upsert",
149
+ resource: "OC_CONSTITUENT",
150
+ record: { firstName: "Bo" },
151
+ match: { EMPLID: "E123" },
152
+ }),
153
+ profile,
154
+ });
155
+ const put = recorded[0];
156
+ expect(put.method).toBe("PUT");
157
+ expect(put.url).toBe("/OC_CONSTITUENT/E123");
158
+ // No echoed id → falls back to the supplied natural key.
159
+ expect(result.sisId).toBe("E123");
160
+ });
161
+ it("throws including the status when the write returns non-2xx", async () => {
162
+ const { url } = await ocStartServer((_req, res) => {
163
+ res.writeHead(500);
164
+ res.end("server error");
165
+ });
166
+ const profile = ocPsProfile(url);
167
+ await expect(ocPeoplesoftCall({
168
+ payload: payload({
169
+ profileId: profile.id,
170
+ operation: "upsert",
171
+ resource: "OC_CONSTITUENT",
172
+ record: { firstName: "X" },
173
+ }),
174
+ profile,
175
+ })).rejects.toThrow(/PeopleSoft POST OC_CONSTITUENT failed \(500\)/);
176
+ });
177
+ });
178
+ describe("ocPeoplesoftCall — read", () => {
179
+ it("normalises a wrapped { data: [...] } collection into flattened rows", async () => {
180
+ const { url, recorded } = await ocStartServer((_req, res) => {
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({
183
+ data: [
184
+ { EMPLID: "E1", name: { last: "A" } },
185
+ { EMPLID: "E2", flag: null },
186
+ ],
187
+ }));
188
+ });
189
+ const profile = ocPsProfile(url);
190
+ const result = await ocPeoplesoftCall({
191
+ payload: payload({
192
+ profileId: profile.id,
193
+ operation: "read",
194
+ resource: "OC_GET",
195
+ match: { EMPLID: "E1" },
196
+ }),
197
+ profile,
198
+ });
199
+ expect(result.rows).toHaveLength(2);
200
+ expect(result.rows[0]).toEqual({ EMPLID: "E1", name: '{"last":"A"}' });
201
+ expect(result.rows[1]).toEqual({ EMPLID: "E2" });
202
+ // Match keys ride both the path segment and the query string.
203
+ const get = recorded[0];
204
+ expect(get.url).toBe("/OC_GET/E1?EMPLID=E1");
205
+ });
206
+ it("throws including the status when read returns non-2xx", async () => {
207
+ const { url } = await ocStartServer((_req, res) => {
208
+ res.writeHead(404);
209
+ res.end("not found");
210
+ });
211
+ const profile = ocPsProfile(url);
212
+ await expect(ocPeoplesoftCall({
213
+ payload: payload({ profileId: profile.id, operation: "read", resource: "OC_GET" }),
214
+ profile,
215
+ })).rejects.toThrow(/PeopleSoft read failed \(404\)/);
216
+ });
217
+ });
218
+ describe("ocPeoplesoftCall — SOAP variant", () => {
219
+ it("POSTs a WS-Security SOAP envelope to the listener and surfaces the match key", async () => {
220
+ const { url, recorded } = await ocStartServer((_req, res) => {
221
+ res.writeHead(200, { "Content-Type": "text/xml" });
222
+ res.end("<soapenv:Envelope><soapenv:Body><Result>ok</Result></soapenv:Body></soapenv:Envelope>");
223
+ });
224
+ const profile = ocPsProfile(url, { authType: "soap" });
225
+ const result = await ocPeoplesoftCall({
226
+ payload: payload({
227
+ profileId: profile.id,
228
+ operation: "upsert",
229
+ resource: "OC_PostConstituent",
230
+ record: { firstName: "Ann" },
231
+ match: { EMPLID: "E777" },
232
+ }),
233
+ profile,
234
+ });
235
+ const soap = recorded[0];
236
+ expect(soap.method).toBe("POST");
237
+ expect(soap.headers["content-type"]).toMatch(/text\/xml/);
238
+ expect(soap.headers["soapaction"]).toBe("OC_PostConstituent");
239
+ // Envelope carries the WS-Security UsernameToken and the data fields.
240
+ expect(soap.body).toContain("<soapenv:Envelope");
241
+ expect(soap.body).toContain("<wsse:Username>isuser</wsse:Username>");
242
+ expect(soap.body).toContain("<wsse:Password>isupass</wsse:Password>");
243
+ expect(soap.body).toContain("<OC_PostConstituent>");
244
+ expect(soap.body).toContain("<firstName>Ann</firstName>");
245
+ expect(soap.body).toContain("<EMPLID>E777</EMPLID>");
246
+ // First match value surfaces as the sisId.
247
+ expect(result.sisId).toBe("E777");
248
+ });
249
+ it("throws when the SOAP body contains a Fault", async () => {
250
+ const { url } = await ocStartServer((_req, res) => {
251
+ res.writeHead(200, { "Content-Type": "text/xml" });
252
+ res.end("<soapenv:Envelope><soapenv:Body><soapenv:Fault><faultstring>nope</faultstring></soapenv:Fault></soapenv:Body></soapenv:Envelope>");
253
+ });
254
+ const profile = ocPsProfile(url, { authType: "soap" });
255
+ await expect(ocPeoplesoftCall({
256
+ payload: payload({
257
+ profileId: profile.id,
258
+ operation: "upsert",
259
+ resource: "OC_PostConstituent",
260
+ record: { firstName: "X" },
261
+ }),
262
+ profile,
263
+ })).rejects.toThrow(/PeopleSoft SOAP OC_PostConstituent failed/);
264
+ });
265
+ });
266
+ //# sourceMappingURL=peoplesoft.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peoplesoft.test.js","sourceRoot":"","sources":["../../src/sis/peoplesoft.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=workday.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workday.test.d.ts","sourceRoot":"","sources":["../../src/sis/workday.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,369 @@
1
+ import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
2
+ import { createServer } from "http";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ // Redirect the agent logger to a writable temp dir BEFORE importing workday.js
6
+ // (transitively constructs the winston file logger). Mirrors binary.test.ts.
7
+ process.env["OC_LOG_DIR"] = join(tmpdir(), "oc-agent-test-logs");
8
+ let ocWorkdayCall;
9
+ beforeAll(async () => {
10
+ ({ ocWorkdayCall } = await import("./workday.js"));
11
+ });
12
+ let openServers = [];
13
+ function ocStartServer(handler) {
14
+ const recorded = [];
15
+ return new Promise((resolve) => {
16
+ const server = createServer((req, res) => {
17
+ const chunks = [];
18
+ req.on("data", (c) => chunks.push(c));
19
+ req.on("end", () => {
20
+ const rec = {
21
+ method: req.method ?? "",
22
+ url: req.url ?? "",
23
+ headers: req.headers,
24
+ body: Buffer.concat(chunks).toString("utf8"),
25
+ };
26
+ recorded.push(rec);
27
+ handler(req, res, rec);
28
+ });
29
+ });
30
+ openServers.push(server);
31
+ server.listen(0, "127.0.0.1", () => {
32
+ const { port } = server.address();
33
+ resolve({ url: `http://127.0.0.1:${port}`, recorded });
34
+ });
35
+ });
36
+ }
37
+ // Await closes so no listener handle leaks (binary.test.ts teardown note).
38
+ async function ocCloseServers() {
39
+ await Promise.all(openServers.map((s) => new Promise((resolve) => s.close(() => resolve()))));
40
+ openServers = [];
41
+ }
42
+ afterEach(ocCloseServers);
43
+ afterAll(ocCloseServers);
44
+ const TENANT = "acme_impl";
45
+ const TOKEN_PATH = `/ccx/oauth2/${TENANT}/token`;
46
+ const CLIENT_BASIC = "Basic " + Buffer.from("cid:csecret").toString("base64");
47
+ let profileSeq = 0;
48
+ function ocWdProfile(baseUrl, over = {}) {
49
+ // Unique id per profile so the process-lifetime bearer cache never bleeds
50
+ // between tests (caching is only asserted where we deliberately reuse one id).
51
+ return {
52
+ id: `wd-${++profileSeq}`,
53
+ vendor: "workday",
54
+ baseUrl,
55
+ authType: "oauth_client_credentials",
56
+ clientId: "cid",
57
+ clientSecret: "csecret",
58
+ tenant: TENANT,
59
+ ...over,
60
+ };
61
+ }
62
+ function payload(over = {}) {
63
+ return {
64
+ profileId: "wd-x",
65
+ vendor: "workday",
66
+ operation: "test",
67
+ resource: "students",
68
+ ...over,
69
+ };
70
+ }
71
+ // Mock token endpoint responder. Returns the access token for a valid grant.
72
+ function tokenOk(res, token = "wd-access-token") {
73
+ res.writeHead(200, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify({ access_token: token, expires_in: 3600 }));
75
+ }
76
+ describe("ocWorkdayCall — test operation", () => {
77
+ it("obtains an OAuth client_credentials token then probes the workers endpoint", async () => {
78
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
79
+ if (rec.url === TOKEN_PATH && rec.method === "POST") {
80
+ tokenOk(res);
81
+ return;
82
+ }
83
+ if (rec.method === "GET" && rec.url.startsWith(`/ccx/api/v1/${TENANT}/workers`)) {
84
+ res.writeHead(200, { "Content-Type": "application/json" });
85
+ res.end(JSON.stringify({ data: [] }));
86
+ return;
87
+ }
88
+ res.writeHead(404);
89
+ res.end();
90
+ });
91
+ const profile = ocWdProfile(url);
92
+ const result = await ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile });
93
+ expect(result.raw).toEqual({ ok: true, vendor: "workday", tenant: TENANT });
94
+ // Token call used client_credentials grant + Basic client auth.
95
+ const tok = recorded.find((r) => r.url === TOKEN_PATH);
96
+ expect(tok.method).toBe("POST");
97
+ expect(tok.headers["authorization"]).toBe(CLIENT_BASIC);
98
+ expect(tok.headers["content-type"]).toMatch(/application\/x-www-form-urlencoded/);
99
+ expect(tok.body).toContain("grant_type=client_credentials");
100
+ // Probe carried the Bearer token.
101
+ const probe = recorded.find((r) => r.url.startsWith(`/ccx/api/v1/${TENANT}/workers`));
102
+ expect(probe.headers["authorization"]).toBe("Bearer wd-access-token");
103
+ });
104
+ it("uses the refresh_token grant when authType is oauth_refresh_token", async () => {
105
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
106
+ if (rec.url === TOKEN_PATH) {
107
+ tokenOk(res);
108
+ return;
109
+ }
110
+ res.writeHead(200, { "Content-Type": "application/json" });
111
+ res.end(JSON.stringify({ data: [] }));
112
+ });
113
+ const profile = ocWdProfile(url, {
114
+ authType: "oauth_refresh_token",
115
+ refreshToken: "rt-abc",
116
+ });
117
+ await ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile });
118
+ const tok = recorded.find((r) => r.url === TOKEN_PATH);
119
+ expect(tok.body).toContain("grant_type=refresh_token");
120
+ expect(tok.body).toContain("refresh_token=rt-abc");
121
+ });
122
+ it("caches the bearer — a second call reuses it (one token hit)", async () => {
123
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
124
+ if (rec.url === TOKEN_PATH) {
125
+ tokenOk(res);
126
+ return;
127
+ }
128
+ res.writeHead(200, { "Content-Type": "application/json" });
129
+ res.end(JSON.stringify({ data: [] }));
130
+ });
131
+ const profile = ocWdProfile(url);
132
+ await ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile });
133
+ await ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile });
134
+ const tokenHits = recorded.filter((r) => r.url === TOKEN_PATH);
135
+ expect(tokenHits).toHaveLength(1);
136
+ });
137
+ it("throws including the status when the token request fails", async () => {
138
+ const { url } = await ocStartServer((_req, res) => {
139
+ res.writeHead(401);
140
+ res.end("bad client");
141
+ });
142
+ const profile = ocWdProfile(url);
143
+ await expect(ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile })).rejects.toThrow(/Workday auth failed \(401\)/);
144
+ });
145
+ it("throws when the token response omits access_token", async () => {
146
+ const { url } = await ocStartServer((_req, res, rec) => {
147
+ if (rec.url === TOKEN_PATH) {
148
+ res.writeHead(200, { "Content-Type": "application/json" });
149
+ res.end(JSON.stringify({ expires_in: 3600 }));
150
+ return;
151
+ }
152
+ res.writeHead(200);
153
+ res.end("{}");
154
+ });
155
+ const profile = ocWdProfile(url);
156
+ await expect(ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile })).rejects.toThrow(/no access_token/i);
157
+ });
158
+ it("throws when the profile has no tenant", async () => {
159
+ const profile = ocWdProfile("http://127.0.0.1:1", { tenant: "" });
160
+ await expect(ocWorkdayCall({ payload: payload({ profileId: profile.id }), profile })).rejects.toThrow(/no tenant/i);
161
+ });
162
+ });
163
+ describe("ocWorkdayCall — REST upsert", () => {
164
+ it("POSTs to the collection when no match ref id is present (create)", async () => {
165
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
166
+ if (rec.url === TOKEN_PATH) {
167
+ tokenOk(res);
168
+ return;
169
+ }
170
+ if (rec.method === "POST") {
171
+ res.writeHead(201, { "Content-Type": "application/json" });
172
+ res.end(JSON.stringify({ id: "wd-id-1" }));
173
+ return;
174
+ }
175
+ res.writeHead(404);
176
+ res.end();
177
+ });
178
+ const profile = ocWdProfile(url);
179
+ const result = await ocWorkdayCall({
180
+ payload: payload({
181
+ profileId: profile.id,
182
+ operation: "upsert",
183
+ resource: "students",
184
+ record: { firstName: "Ann" },
185
+ }),
186
+ profile,
187
+ });
188
+ expect(result.sisId).toBe("wd-id-1");
189
+ // The token request is also a POST — match on the api path to find the write.
190
+ const post = recorded.find((r) => r.method === "POST" && r.url.startsWith("/ccx/api/"));
191
+ expect(post.url).toBe(`/ccx/api/students/${TENANT}?version=v1`);
192
+ expect(post.headers["authorization"]).toBe("Bearer wd-access-token");
193
+ expect(JSON.parse(post.body)).toEqual({ firstName: "Ann" });
194
+ });
195
+ it("PUTs to the keyed instance URL when a match ref id is present (update)", async () => {
196
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
197
+ if (rec.url === TOKEN_PATH) {
198
+ tokenOk(res);
199
+ return;
200
+ }
201
+ res.writeHead(200, { "Content-Type": "application/json" });
202
+ res.end("{}");
203
+ });
204
+ const profile = ocWdProfile(url);
205
+ const result = await ocWorkdayCall({
206
+ payload: payload({
207
+ profileId: profile.id,
208
+ operation: "upsert",
209
+ resource: "students",
210
+ record: { firstName: "Bo" },
211
+ match: { Student_ID: "S55" },
212
+ }),
213
+ profile,
214
+ });
215
+ const put = recorded.find((r) => r.method === "PUT");
216
+ // Ref id becomes a path segment before the ?version query.
217
+ expect(put.url).toBe(`/ccx/api/students/${TENANT}/S55?version=v1`);
218
+ // No echoed id → falls back to the supplied reference id.
219
+ expect(result.sisId).toBe("S55");
220
+ });
221
+ it("honours a custom version segment from the profile", async () => {
222
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
223
+ if (rec.url === TOKEN_PATH) {
224
+ tokenOk(res);
225
+ return;
226
+ }
227
+ res.writeHead(201, { "Content-Type": "application/json" });
228
+ res.end(JSON.stringify({ id: "x" }));
229
+ });
230
+ const profile = ocWdProfile(url, { version: "v40.0" });
231
+ await ocWorkdayCall({
232
+ payload: payload({
233
+ profileId: profile.id,
234
+ operation: "upsert",
235
+ resource: "students",
236
+ record: { a: "b" },
237
+ }),
238
+ profile,
239
+ });
240
+ const post = recorded.find((r) => r.method === "POST" && r.url.startsWith("/ccx/api/"));
241
+ expect(post.url).toBe(`/ccx/api/students/${TENANT}?version=v40.0`);
242
+ });
243
+ it("throws including the status when the REST write returns non-2xx", async () => {
244
+ const { url } = await ocStartServer((_req, res, rec) => {
245
+ if (rec.url === TOKEN_PATH) {
246
+ tokenOk(res);
247
+ return;
248
+ }
249
+ res.writeHead(500);
250
+ res.end("boom");
251
+ });
252
+ const profile = ocWdProfile(url);
253
+ await expect(ocWorkdayCall({
254
+ payload: payload({
255
+ profileId: profile.id,
256
+ operation: "upsert",
257
+ resource: "students",
258
+ record: { a: "b" },
259
+ }),
260
+ profile,
261
+ })).rejects.toThrow(/Workday POST students failed \(500\)/);
262
+ });
263
+ });
264
+ describe("ocWorkdayCall — SOAP upsert routing", () => {
265
+ it("POSTs a SOAP envelope to the service endpoint when record.__transport is soap", async () => {
266
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
267
+ if (rec.url === TOKEN_PATH) {
268
+ tokenOk(res);
269
+ return;
270
+ }
271
+ res.writeHead(200, { "Content-Type": "text/xml" });
272
+ res.end("<soapenv:Envelope><soapenv:Body><Put_Student_Response/></soapenv:Body></soapenv:Envelope>");
273
+ });
274
+ const profile = ocWdProfile(url);
275
+ const result = await ocWorkdayCall({
276
+ payload: payload({
277
+ profileId: profile.id,
278
+ operation: "upsert",
279
+ resource: "Put_Student",
280
+ record: { __transport: "soap", First_Name: "Ann" },
281
+ match: { Student_ID: "S900" },
282
+ }),
283
+ profile,
284
+ });
285
+ const soap = recorded.find((r) => r.method === "POST" && r.url.includes("/ccx/service/"));
286
+ expect(soap).toBeDefined();
287
+ expect(soap.url).toBe(`/ccx/service/${TENANT}/v1`);
288
+ expect(soap.headers["content-type"]).toMatch(/text\/xml/);
289
+ expect(soap.headers["soapaction"]).toBe("Put_Student");
290
+ expect(soap.headers["authorization"]).toBe("Bearer wd-access-token");
291
+ // Envelope carries the operation request, the typed Reference ID, and data
292
+ // fields — but NOT the __transport hint.
293
+ expect(soap.body).toContain("<wd:Put_Student_Request>");
294
+ expect(soap.body).toContain('<wd:ID wd:type="Student_ID">S900</wd:ID>');
295
+ expect(soap.body).toContain("<wd:First_Name>Ann</wd:First_Name>");
296
+ expect(soap.body).not.toContain("__transport");
297
+ // First match value surfaces as the sisId.
298
+ expect(result.sisId).toBe("S900");
299
+ });
300
+ it("throws when the SOAP response contains a Fault", async () => {
301
+ const { url } = await ocStartServer((_req, res, rec) => {
302
+ if (rec.url === TOKEN_PATH) {
303
+ tokenOk(res);
304
+ return;
305
+ }
306
+ res.writeHead(200, { "Content-Type": "text/xml" });
307
+ res.end("<soapenv:Envelope><soapenv:Body><soapenv:Fault><faultstring>no</faultstring></soapenv:Fault></soapenv:Body></soapenv:Envelope>");
308
+ });
309
+ const profile = ocWdProfile(url);
310
+ await expect(ocWorkdayCall({
311
+ payload: payload({
312
+ profileId: profile.id,
313
+ operation: "upsert",
314
+ resource: "Put_Student",
315
+ record: { __transport: "soap", First_Name: "X" },
316
+ }),
317
+ profile,
318
+ })).rejects.toThrow(/Workday SOAP Put_Student failed/);
319
+ });
320
+ });
321
+ describe("ocWorkdayCall — read", () => {
322
+ it("returns flattened rows from a { data: [...] } collection", async () => {
323
+ const { url, recorded } = await ocStartServer((_req, res, rec) => {
324
+ if (rec.url === TOKEN_PATH) {
325
+ tokenOk(res);
326
+ return;
327
+ }
328
+ res.writeHead(200, { "Content-Type": "application/json" });
329
+ res.end(JSON.stringify({
330
+ data: [
331
+ { id: "1", descriptor: { name: "Ann" }, active: true },
332
+ { id: "2", note: null },
333
+ ],
334
+ }));
335
+ });
336
+ const profile = ocWdProfile(url);
337
+ const result = await ocWorkdayCall({
338
+ payload: payload({
339
+ profileId: profile.id,
340
+ operation: "read",
341
+ resource: "students",
342
+ match: { Student_ID: "1" },
343
+ }),
344
+ profile,
345
+ });
346
+ expect(result.rows).toHaveLength(2);
347
+ expect(result.rows[0]).toEqual({ id: "1", descriptor: '{"name":"Ann"}', active: "true" });
348
+ expect(result.rows[1]).toEqual({ id: "2" });
349
+ // Match keys appended as query filters alongside the version param.
350
+ const get = recorded.find((r) => r.method === "GET");
351
+ expect(get.url).toBe(`/ccx/api/students/${TENANT}?version=v1&Student_ID=1`);
352
+ });
353
+ it("throws including the status when read returns non-2xx", async () => {
354
+ const { url } = await ocStartServer((_req, res, rec) => {
355
+ if (rec.url === TOKEN_PATH) {
356
+ tokenOk(res);
357
+ return;
358
+ }
359
+ res.writeHead(403);
360
+ res.end("forbidden");
361
+ });
362
+ const profile = ocWdProfile(url);
363
+ await expect(ocWorkdayCall({
364
+ payload: payload({ profileId: profile.id, operation: "read", resource: "students" }),
365
+ profile,
366
+ })).rejects.toThrow(/Workday read failed \(403\)/);
367
+ });
368
+ });
369
+ //# sourceMappingURL=workday.test.js.map