diff --git a/sdk/core/core-client-rest/test/internal/clientHelpers.spec.ts b/sdk/core/core-client-rest/test/internal/clientHelpers.spec.ts index 43efcd8504f0..71a2c7afa1b7 100644 --- a/sdk/core/core-client-rest/test/internal/clientHelpers.spec.ts +++ b/sdk/core/core-client-rest/test/internal/clientHelpers.spec.ts @@ -69,6 +69,16 @@ describe("clientHelpers", () => { ); }); + it("should not treat a non-string key property as a KeyCredential", () => { + const pipeline = createDefaultPipeline(mockBaseUrl, { key: 123 } as any); + const policies = pipeline.getOrderedPolicies(); + + assert.isUndefined( + policies.find((p) => p.name === keyCredentialAuthenticationPolicyName), + "pipeline should not have keyCredentialAuthenticationPolicyName for non-string key", + ); + }); + it("should create a default pipeline with TokenCredential", () => { const mockCredential: TokenCredential = { getToken: async () => ({ expiresOnTimestamp: 0, token: "mockToken" }), diff --git a/sdk/core/core-client/test/internal/authorizeRequestOnClaimChallenge.spec.ts b/sdk/core/core-client/test/internal/authorizeRequestOnClaimChallenge.spec.ts index 461a37a34c64..72778f903028 100644 --- a/sdk/core/core-client/test/internal/authorizeRequestOnClaimChallenge.spec.ts +++ b/sdk/core/core-client/test/internal/authorizeRequestOnClaimChallenge.spec.ts @@ -16,9 +16,11 @@ import { } from "../../src/authorizeRequestOnClaimChallenge.js"; import { encodeString } from "../../src/base64.js"; +const defaultRequest = () => createPipelineRequest({ url: "https://example.com" }); + describe("authorizeRequestOnClaimChallenge", function () { it(`should try to get the access token if the response has a valid claims parameter on the WWW-Authenticate header`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -55,13 +57,13 @@ describe("authorizeRequestOnClaimChallenge", function () { scopes: ["https://endpoint/.default"], getTokenOptions: { claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}', - } as GetTokenOptions, + } satisfies GetTokenOptions, }, ]); }); it(`should try to get the access token with the parametrized scopes if the response has no scope property on the WWW-authenticate header`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -97,7 +99,7 @@ describe("authorizeRequestOnClaimChallenge", function () { scopes: ["https://parametrized-endpoint/.default"], getTokenOptions: { claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}', - } as GetTokenOptions, + } satisfies GetTokenOptions, }, ]); }); @@ -106,7 +108,7 @@ describe("authorizeRequestOnClaimChallenge", function () { // In Python, padding has to be added at the end if the size of the base64 string is not a multiple of 4. // In JavaScript, the padding is added automatically. - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -143,13 +145,13 @@ describe("authorizeRequestOnClaimChallenge", function () { scopes: ["https://parametrized-endpoint/.default"], getTokenOptions: { claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}', - } as GetTokenOptions, + } satisfies GetTokenOptions, }, ]); }); it(`should return false if getAccessToken is called and if it doesn't return an access token`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -182,13 +184,13 @@ describe("authorizeRequestOnClaimChallenge", function () { scopes: ["https://parametrized-endpoint/.default"], getTokenOptions: { claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}', - } as GetTokenOptions, + } satisfies GetTokenOptions, }, ]); }); it(`should return false if the response has an invalid claims parameter on the WWW-Authenticate header`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -216,7 +218,7 @@ describe("authorizeRequestOnClaimChallenge", function () { }); it(`should return false if the response has no WWW-Authenticate header`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -283,7 +285,7 @@ describe("authorizeRequestOnClaimChallenge", function () { }), }; - const pipelineRequest = createPipelineRequest({ url: "https://example.com" }); + const pipelineRequest = defaultRequest(); const responses: PipelineResponse[] = [ { headers: createHttpHeaders({ @@ -353,7 +355,7 @@ describe("authorizeRequestOnClaimChallenge", function () { }); it(`a custom logger should log a reasonable message if no challenge is received`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -391,7 +393,7 @@ describe("authorizeRequestOnClaimChallenge", function () { }); it(`a custom logger should log a reasonable message if a bad challenge is received`, async function () { - const request = createPipelineRequest({ url: "https://example.com" }); + const request = defaultRequest(); const getAccessTokenParameters: { scopes: string | string[]; getTokenOptions: GetTokenOptions; @@ -436,3 +438,62 @@ describe("authorizeRequestOnClaimChallenge", function () { ); }); }); + +describe("authorizeRequestOnClaimChallenge", () => { + it("should handle malformed WWW-Authenticate header (no claims)", async () => { + const request = defaultRequest(); + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken() { + return { token: "token", expiresOnTimestamp: Date.now() + 3600000 }; + }, + scopes: [], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": 'Bearer realm="test"', + }), + request, + status: 401, + }, + request, + }); + assert.isFalse(result); + }); + + it("should handle missing WWW-Authenticate header", async () => { + const request = defaultRequest(); + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken() { + return { token: "token", expiresOnTimestamp: Date.now() + 3600000 }; + }, + scopes: [], + response: { + headers: createHttpHeaders(), + request, + status: 401, + }, + request, + }); + assert.isFalse(result); + }); +}); + +describe("authorizeRequestOnClaimChallenge", () => { + it("should handle completely unparseable WWW-Authenticate value", async () => { + const request = defaultRequest(); + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken() { + return { token: "token", expiresOnTimestamp: Date.now() + 3600000 }; + }, + scopes: [], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": "NotBearer gibberish", + }), + request, + status: 401, + }, + request, + }); + assert.isFalse(result); + }); +}); diff --git a/sdk/core/core-client/test/internal/base64.spec.ts b/sdk/core/core-client/test/internal/base64.spec.ts new file mode 100644 index 000000000000..0c056a553292 --- /dev/null +++ b/sdk/core/core-client/test/internal/base64.spec.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import { encodeByteArray } from "../../src/base64.js"; + +describe("base64", () => { + it("should handle Uint8Array input in encodeByteArray", () => { + const arr = new Uint8Array([72, 101, 108, 108, 111]); + const result = encodeByteArray(arr); + assert.strictEqual(result, "SGVsbG8="); + }); +}); diff --git a/sdk/core/core-client/test/internal/deserializationPolicy.spec.ts b/sdk/core/core-client/test/internal/deserializationPolicy.spec.ts index a0d4defae271..f91b01a2dcd5 100644 --- a/sdk/core/core-client/test/internal/deserializationPolicy.spec.ts +++ b/sdk/core/core-client/test/internal/deserializationPolicy.spec.ts @@ -1,17 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, assert, vi } from "vitest"; +import { describe, it, assert, expect, vi } from "vitest"; import type { CompositeMapper, FullOperationResponse, OperationRequest, OperationSpec, + SequenceMapper, SerializerOptions, } from "../../src/index.js"; -import { createSerializer, deserializationPolicy } from "../../src/index.js"; +import { createSerializer, deserializationPolicy, ServiceClient } from "../../src/index.js"; import type { PipelineResponse, RawHttpHeaders, SendRequest } from "@azure/core-rest-pipeline"; -import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline"; +import { + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, +} from "@azure/core-rest-pipeline"; import { getOperationRequestInfo } from "../../src/operationHelpers.js"; import { parseXML } from "@azure/core-xml"; @@ -449,19 +454,19 @@ describe("deserializationPolicy", function () { serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: { "x-ms-error-code": "InvalidResourceNameHeader" }, bodyAsText: '{"message": "InvalidResourceNameBody"}', status: 500, - }); - assert.fail(); - } catch (e: any) { - assert.exists(e); - assert.strictEqual(e.response.parsedHeaders.errorCode, "InvalidResourceNameHeader"); - assert.strictEqual(e.response.parsedBody.message, "InvalidResourceNameBody"); - } + }), + ).rejects.toMatchObject({ + response: { + parsedHeaders: { errorCode: "InvalidResourceNameHeader" }, + parsedBody: { message: "InvalidResourceNameBody" }, + }, + }); }); it(`with non default error response headers`, async function () { @@ -510,19 +515,19 @@ describe("deserializationPolicy", function () { serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: { "x-ms-error-code": "InvalidResourceNameHeader" }, bodyAsText: '{"message": "InvalidResourceNameBody"}', status: 500, - }); - assert.fail(); - } catch (e: any) { - assert.exists(e); - assert.strictEqual(e.response.parsedHeaders.errorCode, "InvalidResourceNameHeader"); - assert.strictEqual(e.response.parsedBody.message, "InvalidResourceNameBody"); - } + }), + ).rejects.toMatchObject({ + response: { + parsedHeaders: { errorCode: "InvalidResourceNameHeader" }, + parsedBody: { message: "InvalidResourceNameBody" }, + }, + }); }); it(`should throw when the response code is not defined in the operationSpec`, async function () { @@ -535,19 +540,17 @@ describe("deserializationPolicy", function () { }, serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: {}, bodyAsText: '{"message": "InternalServerError"}', status: 400, - }); - assert.fail(); - } catch (e: any) { - assert(e); - assert.strictEqual(e.statusCode, 400); - assert.include(e.message, "InternalServerError"); - } + }), + ).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining("InternalServerError"), + }); }); it(`with non default complex error response`, async function () { @@ -606,22 +609,24 @@ describe("deserializationPolicy", function () { serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: { "x-ms-error-code": "InvalidResourceNameHeader" }, bodyAsText: '{"message1": "InvalidResourceNameBody1", "message2": "InvalidResourceNameBody2", "message3": "InvalidResourceNameBody3"}', status: 503, - }); - assert.fail(); - } catch (e: any) { - assert.exists(e); - assert.strictEqual(e.response.parsedHeaders.errorCode, "InvalidResourceNameHeader"); - assert.strictEqual(e.response.parsedBody.message1, "InvalidResourceNameBody1"); - assert.strictEqual(e.response.parsedBody.message2, "InvalidResourceNameBody2"); - assert.strictEqual(e.response.parsedBody.message3, "InvalidResourceNameBody3"); - } + }), + ).rejects.toMatchObject({ + response: { + parsedHeaders: { errorCode: "InvalidResourceNameHeader" }, + parsedBody: { + message1: "InvalidResourceNameBody1", + message2: "InvalidResourceNameBody2", + message3: "InvalidResourceNameBody3", + }, + }, + }); }); it(`with default error response body`, async function () { @@ -661,25 +666,24 @@ describe("deserializationPolicy", function () { serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: {}, bodyAsText: '{"Code": "ContainerAlreadyExists", "Message": "The specified container already exists."}', status: 500, - }); - assert.fail(); - } catch (e: any) { - assert.exists(e); - assert.strictEqual(e.code, "ContainerAlreadyExists"); - assert.strictEqual(e.message, "The specified container already exists."); - assert.strictEqual(e.response.parsedBody.code, "ContainerAlreadyExists"); - assert.strictEqual( - e.response.parsedBody.message, - "The specified container already exists.", - ); - } + }), + ).rejects.toMatchObject({ + code: "ContainerAlreadyExists", + message: "The specified container already exists.", + response: { + parsedBody: { + code: "ContainerAlreadyExists", + message: "The specified container already exists.", + }, + }, + }); }); it(`heuristic for error body without default body mapper`, async function () { @@ -712,27 +716,26 @@ describe("deserializationPolicy", function () { serializer, }; - try { - await getDeserializedResponse({ + await expect( + getDeserializedResponse({ operationSpec, headers: {}, bodyAsText: `{"error":{"code":"SubscriptionNotFound","message":"The subscription 'ae0a5678-da86-4bd9-a3a2-9a7558415de5' could not be found."}}`, status: 404, - }); - assert.fail(); - } catch (e: any) { - assert.exists(e); - assert.strictEqual(e.code, "SubscriptionNotFound"); - assert.strictEqual( - e.message, - "The subscription 'ae0a5678-da86-4bd9-a3a2-9a7558415de5' could not be found.", - ); - assert.strictEqual(e.response.parsedBody.error.code, "SubscriptionNotFound"); - assert.strictEqual( - e.response.parsedBody.error.message, - "The subscription 'ae0a5678-da86-4bd9-a3a2-9a7558415de5' could not be found.", - ); - } + }), + ).rejects.toMatchObject({ + code: "SubscriptionNotFound", + message: "The subscription 'ae0a5678-da86-4bd9-a3a2-9a7558415de5' could not be found.", + response: { + parsedBody: { + error: { + code: "SubscriptionNotFound", + message: + "The subscription 'ae0a5678-da86-4bd9-a3a2-9a7558415de5' could not be found.", + }, + }, + }, + }); }); it(`json response with headers`, async function () { @@ -875,3 +878,721 @@ async function getDeserializedResponse( const response = await policy.sendRequest(request, next); return response; } + +describe("deserializationPolicy", () => { + it("should deserialize JSON response body when shouldDeserialize is true", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + return Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: '{"id": 1}', + }); + }, + }, + pipeline, + }); + + // Ensure the operationResponseGetter path is available through sendOperationRequest + const result = await client.sendOperationRequest( + { + options: { + requestOptions: { + shouldDeserialize: true, + }, + }, + }, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }, + }, + }, + }, + ); + assert.strictEqual((result as any).id, 1); + }); + + it("should handle shouldDeserialize as a function", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: '{"id": 1}', + }), + }, + pipeline, + }); + + const result = await client.sendOperationRequest( + { + options: { + requestOptions: { + shouldDeserialize: (response: PipelineResponse) => response.status === 200, + }, + }, + }, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { 200: {} }, + }, + ); + assert.deepStrictEqual((result as any).body, { id: 1 }); + }); + + it("should return boolean body for HEAD request", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + }), + }, + pipeline, + }); + + const result = await client.sendOperationRequest( + {}, + { + httpMethod: "HEAD", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: {}, + }, + }, + ); + assert.deepStrictEqual(result, { body: true }); + }); + + it("should handle parsedHeaders from headersMapper", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders({ "x-custom": "value123" }), + bodyAsText: '{"id": 1}', + }), + }, + pipeline, + }); + + const result: any = await client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }, + headersMapper: { + type: { + name: "Composite", + modelProperties: { + xCustom: { + serializedName: "x-custom", + type: { name: "String" }, + }, + }, + }, + }, + }, + }, + }, + ); + assert.strictEqual(result.xCustom, "value123"); + }); + + it("should wrap error with error headers mapper", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 400, + headers: createHttpHeaders({ "x-error-id": "err123" }), + bodyAsText: '{"error": {"code": "BadRequest", "message": "Invalid input"}}', + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: {}, + default: { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + error: { + serializedName: "error", + type: { + name: "Composite", + modelProperties: { + code: { serializedName: "code", type: { name: "String" } }, + message: { serializedName: "message", type: { name: "String" } }, + }, + }, + }, + }, + }, + }, + headersMapper: { + type: { + name: "Composite", + modelProperties: { + xErrorId: { + serializedName: "x-error-id", + type: { name: "String" }, + }, + }, + }, + }, + }, + }, + }, + ), + ).rejects.toMatchObject({ code: "BadRequest" }); + }); + + it("should handle XML parsing error", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + deserializationPolicy({ + expectedContentTypes: { + json: ["application/json"], + xml: ["application/xml"], + }, + parseXML: async () => { + throw new Error("XML parse error"); + }, + }), + { phase: "Deserialize" }, + ); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders({ "Content-Type": "application/xml" }), + bodyAsText: "xml", + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { 200: {} }, + }, + ), + ).rejects.toThrow(/XML parse error/); + }); + + it("should handle JSON parse error", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders({ "Content-Type": "application/json" }), + bodyAsText: "not valid json{{{", + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { 200: {} }, + }, + ), + ).rejects.toThrow(/occurred while parsing the response body/); + }); +}); + +describe("deserializationPolicy - additional branches", () => { + // Tests the code path in handleErrorResponse where xmlElementName is used to extract + // the array from the parsed body (line: `valueToDeserialize = parsedBody[elementName]`). + // parseXML is faked with JSON.parse for simplicity since the code path is the same. + it("should handle XML Sequence error body with xmlElementName", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + deserializationPolicy({ + expectedContentTypes: { + json: [], + xml: ["application/xml"], + }, + parseXML: async (str) => JSON.parse(str), + }), + { phase: "Deserialize" }, + ); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 400, + headers: createHttpHeaders({ "Content-Type": "application/xml" }), + bodyAsText: JSON.stringify({ + Error: [{ code: "Err1", message: "msg1" }], + }), + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + isXML: true, + serializer: createSerializer({}, true), + responses: { + 200: {}, + default: { + bodyMapper: { + xmlElementName: "Error", + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + modelProperties: { + code: { serializedName: "code", type: { name: "String" } }, + message: { serializedName: "message", type: { name: "String" } }, + }, + }, + }, + }, + } satisfies SequenceMapper, + }, + }, + }, + ), + ).rejects.toThrow(/Err1|msg1/); + }); + + it("should handle missing className in error body mapper", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 400, + headers: createHttpHeaders(), + bodyAsText: '{"error": {"code": "BadRequest", "message": "fail"}}', + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: {}, + default: { + bodyMapper: { + type: { + name: "Composite", + className: "BrokenModel", + }, + } satisfies CompositeMapper, + }, + }, + }, + ), + ).rejects.toThrow(/occurred in deserializing the responseBody/); + }); + + it("should handle XML content-type parsing without parseXML", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + deserializationPolicy({ + expectedContentTypes: { + json: [], + xml: ["application/xml"], + }, + }), + { phase: "Deserialize" }, + ); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders({ "Content-Type": "application/xml" }), + bodyAsText: "test", + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { 200: {} }, + }, + ), + ).rejects.toThrow(/Parsing XML not supported/); + }); + + it("should handle no operationSpec in request", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + }), + }, + pipeline, + }); + + // Directly send a request without setting up operationSpec + const result = await client.sendRequest(createPipelineRequest({ url: "https://example.com" })); + assert.strictEqual(result.status, 200); + }); + + it("should set blobBody and readableStreamBody for Stream-type response", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: "stream content", + }), + }, + pipeline, + }); + + const result = await client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + bodyMapper: { + type: { name: "Stream" }, + }, + }, + }, + }, + ); + assert.property(result as Record, "blobBody"); + assert.property(result as Record, "readableStreamBody"); + }); + + it("should deserialize XML body in success response", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + deserializationPolicy({ + expectedContentTypes: { + json: [], + xml: ["application/xml"], + }, + parseXML: async (str) => JSON.parse(str), + }), + { phase: "Deserialize" }, + ); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders({ "Content-Type": "application/xml" }), + bodyAsText: JSON.stringify({ Items: { Item: ["a", "b"] } }), + }), + }, + pipeline, + }); + + const result = await client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + isXML: true, + serializer: createSerializer({}, true), + responses: { + 200: { + bodyMapper: { + xmlElementName: "Item", + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + } satisfies SequenceMapper, + }, + }, + }, + ); + assert.isArray(result); + }); + + it("should handle isError response spec", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: '{"error": {"code": "SoftError", "message": "recoverable"}}', + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + isError: true, + }, + }, + }, + ), + ).rejects.toThrow(/SoftError|recoverable/); + }); +}); + +describe("deserializationPolicy - operationResponseGetter", () => { + it("should use operationResponseGetter when set", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + // We need to set operationResponseGetter directly on the operationInfo + // This requires intercepting the request before it goes through the pipeline + const customPolicy = { + name: "setOperationResponseGetter", + async sendRequest(request: any, next: any) { + const info = getOperationRequestInfo(request); + info.operationResponseGetter = (_spec: any, response: any) => { + return _spec.responses[response.status]; + }; + return next(request); + }, + }; + pipeline.addPolicy(customPolicy); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: '{"id": 42}', + }), + }, + pipeline, + }); + + const result: any = await client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }, + }, + }, + }, + ); + assert.strictEqual(result.id, 42); + }); +}); + +describe("deserializationPolicy - shouldReturnResponse path", () => { + it("should fall back to default response when status code is unmatched", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 204, + headers: createHttpHeaders(), + }), + }, + pipeline, + }); + + // operationSpec with only a default response, and status 204 not in responses + // => isExpectedStatusCode false, but then we match the default response + // For the shouldReturnResponse path, we need a response that's not in the spec + // AND no default response AND no error body + const result = await client.sendOperationRequest( + {}, + { + httpMethod: "DELETE", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + // only default, no 204 match + default: {}, + }, + }, + ); + // Default response matched (204 not in responses, but default: {} exists). + // Result should be the flattened response — verify it succeeded without throwing. + assert.isDefined(result); + }); +}); + +describe("deserializationPolicy - deserialization error", () => { + it("should throw RestError when body deserialization fails", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(deserializationPolicy(), { phase: "Deserialize" }); + + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + bodyAsText: '{"value": "not-a-number"}', + }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + {}, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { + 200: { + bodyMapper: { + type: { + name: "Composite", + className: "NonExistentModel", + }, + } satisfies CompositeMapper, + }, + }, + }, + ), + ).rejects.toThrow(/occurred in deserializing the responseBody/); + }); +}); diff --git a/sdk/core/core-client/test/internal/interfaceHelpers.spec.ts b/sdk/core/core-client/test/internal/interfaceHelpers.spec.ts new file mode 100644 index 000000000000..8c0b57f8b35e --- /dev/null +++ b/sdk/core/core-client/test/internal/interfaceHelpers.spec.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import type { ParameterPath } from "../../src/interfaces.js"; +import { getPathStringFromParameter } from "../../src/interfaceHelpers.js"; + +describe("interfaceHelpers", () => { + it("should fall back to mapper.serializedName when parameterPath is an object", () => { + const result = getPathStringFromParameter({ + parameterPath: { a: "a" } as ParameterPath, + mapper: { + serializedName: "fallbackName", + type: { name: "Composite" }, + }, + }); + assert.strictEqual(result, "fallbackName"); + }); +}); diff --git a/sdk/core/core-client/test/internal/node/base64.spec.ts b/sdk/core/core-client/test/internal/node/base64.spec.ts new file mode 100644 index 000000000000..32dc24cf8f02 --- /dev/null +++ b/sdk/core/core-client/test/internal/node/base64.spec.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import { encodeByteArray } from "../../../src/base64.js"; + +describe("base64 (Node)", () => { + it("should handle Buffer input directly in encodeByteArray", () => { + const buf = Buffer.from("hello world"); + const result = encodeByteArray(buf); + assert.strictEqual(result, buf.toString("base64")); + }); +}); diff --git a/sdk/core/core-client/test/internal/operationHelpers.spec.ts b/sdk/core/core-client/test/internal/operationHelpers.spec.ts new file mode 100644 index 000000000000..6d6b4b6241fc --- /dev/null +++ b/sdk/core/core-client/test/internal/operationHelpers.spec.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import type { CompositeMapper } from "../../src/index.js"; +import { createSerializer } from "../../src/index.js"; +import type { PipelineRequest } from "@azure/core-rest-pipeline"; +import { createPipelineRequest } from "@azure/core-rest-pipeline"; +import { + getOperationArgumentValueFromParameter, + getOperationRequestInfo, +} from "../../src/operationHelpers.js"; + +describe("operationHelpers", () => { + it("should handle composite parameterPath (object form)", () => { + const result = getOperationArgumentValueFromParameter( + { propA: "valueA", propB: "valueB", extraProp: "ignored" }, + { + parameterPath: { + propA: "propA", + propB: "propB", + }, + mapper: { + serializedName: "composite", + required: true, + type: { + name: "Composite", + modelProperties: { + propA: { + serializedName: "propA", + type: { name: "String" }, + }, + propB: { + serializedName: "propB", + type: { name: "String" }, + }, + }, + }, + } satisfies CompositeMapper, + }, + ); + // Only the mapped properties are extracted; extraProp is not included + assert.deepStrictEqual(result, { propA: "valueA", propB: "valueB" }); + assert.notProperty(result, "extraProp"); + }); + + it("should handle composite parameterPath with non-required mapper and no matching args", () => { + const result = getOperationArgumentValueFromParameter( + {}, + { + parameterPath: { + propA: "propA", + }, + mapper: { + serializedName: "composite", + required: false, + type: { + name: "Composite", + modelProperties: { + propA: { + serializedName: "propA", + type: { name: "String" }, + }, + }, + }, + } satisfies CompositeMapper, + }, + ); + assert.isUndefined(result); + }); + + it("should handle composite parameterPath where mapper is not required but property is found", () => { + const result = getOperationArgumentValueFromParameter( + { propA: "hello" }, + { + parameterPath: { + propA: "propA", + propB: "propB", + }, + mapper: { + serializedName: "composite", + required: false, + type: { + name: "Composite", + modelProperties: { + propA: { + serializedName: "propA", + type: { name: "String" }, + }, + propB: { + serializedName: "propB", + type: { name: "String" }, + }, + }, + }, + } satisfies CompositeMapper, + }, + ); + assert.deepStrictEqual(result, { propA: "hello" }); + }); + + it("should follow originalRequest symbol in getOperationRequestInfo", () => { + const originalRequestSymbol = Symbol.for("@azure/core-client original request"); + const innerRequest = createPipelineRequest({ url: "https://example.com" }); + const outerRequest = createPipelineRequest({ + url: "https://example.com/outer", + }) as PipelineRequest & Record; + outerRequest[originalRequestSymbol] = innerRequest; + + const info1 = getOperationRequestInfo(innerRequest); + info1.operationSpec = { httpMethod: "GET", responses: {}, serializer: createSerializer() }; + + const info2 = getOperationRequestInfo(outerRequest); + assert.strictEqual(info2.operationSpec?.httpMethod, "GET"); + }); +}); + +describe("operationHelpers - array parameterPath empty check", () => { + it("should handle empty string parameterPath", () => { + const result = getOperationArgumentValueFromParameter( + { "": "rootValue" }, + { + parameterPath: "", + mapper: { + serializedName: "test", + type: { name: "String" }, + }, + }, + ); + // Empty string parameterPath becomes [""], which has length > 0 + assert.strictEqual(result, "rootValue"); + }); +}); diff --git a/sdk/core/core-client/test/internal/pipeline.spec.ts b/sdk/core/core-client/test/internal/pipeline.spec.ts new file mode 100644 index 000000000000..4c25c203df26 --- /dev/null +++ b/sdk/core/core-client/test/internal/pipeline.spec.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import { createClientPipeline } from "../../src/pipeline.js"; + +describe("pipeline", () => { + it("should add bearerTokenAuthenticationPolicy when credentialOptions is provided", () => { + const pipeline = createClientPipeline({ + credentialOptions: { + credential: { + getToken: async () => ({ token: "test", expiresOnTimestamp: Date.now() + 3600000 }), + }, + credentialScopes: "https://example.com/.default", + }, + }); + const policies = pipeline.getOrderedPolicies(); + const hasBearerPolicy = policies.some((p) => p.name === "bearerTokenAuthenticationPolicy"); + assert.isTrue(hasBearerPolicy); + }); + + it("should work without credentialOptions", () => { + const pipeline = createClientPipeline({}); + const policies = pipeline.getOrderedPolicies(); + const hasBearerPolicy = policies.some((p) => p.name === "bearerTokenAuthenticationPolicy"); + assert.isFalse(hasBearerPolicy); + }); +}); + +describe("pipeline - default options parameter", () => { + it("should handle being called with no arguments", () => { + const pipeline = createClientPipeline(); + assert.isDefined(pipeline); + const policies = pipeline.getOrderedPolicies(); + assert.isNotEmpty(policies); + }); +}); diff --git a/sdk/core/core-client/test/internal/serializationPolicy.spec.ts b/sdk/core/core-client/test/internal/serializationPolicy.spec.ts index c82735f580b3..8829bfb40db1 100644 --- a/sdk/core/core-client/test/internal/serializationPolicy.spec.ts +++ b/sdk/core/core-client/test/internal/serializationPolicy.spec.ts @@ -1,11 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, assert } from "vitest"; -import { MapperTypeNames, createSerializer } from "../../src/index.js"; +import { describe, it, assert, expect, vi } from "vitest"; +import type { CompositeMapper, OperationRequest, SequenceMapper } from "../../src/index.js"; +import { + MapperTypeNames, + ServiceClient, + createSerializer, + serializationPolicy, +} from "../../src/index.js"; import { serializeHeaders, serializeRequestBody } from "../../src/serializationPolicy.js"; import { Mappers } from "../testMappers1.js"; -import { createPipelineRequest } from "@azure/core-rest-pipeline"; +import { + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, +} from "@azure/core-rest-pipeline"; import { stringifyXML } from "@azure/core-xml"; describe("serializationPolicy", function () { @@ -234,7 +244,7 @@ describe("serializationPolicy", function () { assert.strictEqual(httpRequest.body, "body value"); }); - it("should serialize a JSON Stream request body", () => { + it("should serialize a JSON Stream request body with XML namespace", () => { const httpRequest = createPipelineRequest({ url: "https://example.com" }); serializeRequestBody( httpRequest, @@ -562,7 +572,7 @@ describe("serializationPolicy", function () { serializer: createSerializer(), }, ); - assert.deepEqual(httpRequest.body, JSON.stringify(["Foo", "Bar"])); + assert.strictEqual(httpRequest.body, JSON.stringify(["Foo", "Bar"])); }); it("should serialize an XML Dictionary request body", () => { @@ -683,7 +693,7 @@ describe("serializationPolicy", function () { }, stringifyXML, ); - assert.deepEqual(httpRequest.body, `{"alpha":"hello","beta":"world"}`); + assert.strictEqual(httpRequest.body, `{"alpha":"hello","beta":"world"}`); }); it("should serialize an XML request body with custom xml char key", () => { @@ -841,3 +851,560 @@ describe("serializationPolicy", function () { function stringToByteArray(str: string): Uint8Array { return new TextEncoder().encode(str); } + +describe("serializationPolicy", () => { + it("should serialize formData parameters", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { file: "fileContent" }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + serializer: createSerializer(), + formDataParameters: [ + { + parameterPath: "file", + mapper: { + serializedName: "file", + type: { name: "String" }, + }, + }, + ], + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + assert.deepStrictEqual(capturedRequest?.formData, { file: "fileContent" }); + }); + + it("should handle text/plain content type without JSON stringifying", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ + request: req, + status: 200, + headers: createHttpHeaders(), + }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: "plain text content" }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + contentType: "text/plain", + mediaType: "text", + serializer: createSerializer(), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + type: { name: "String" }, + }, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + assert.strictEqual(capturedRequest?.body, "plain text content"); + }); +}); + +describe("serializationPolicy - XML serialization", () => { + it("should throw XML serialization unsupported when no stringifyXML provided", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + { body: { name: "test" } }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + xmlName: "TestBody", + type: { + name: "Composite", + modelProperties: { + name: { serializedName: "name", xmlName: "name", type: { name: "String" } }, + }, + }, + } satisfies CompositeMapper, + }, + responses: { 200: {} }, + }, + ), + ).rejects.toThrow(/XML serialization unsupported/); + }); + + it("should serialize XML Sequence with stringifyXML", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy({ stringifyXML: (obj) => JSON.stringify(obj) }), { + phase: "Serialize", + }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: ["item1", "item2"] }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "Items", + xmlName: "Items", + xmlElementName: "Item", + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + } satisfies SequenceMapper, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + const body = capturedRequest!.body as string; + assert.strictEqual(body, JSON.stringify({ Item: ["item1", "item2"] })); + }); + + it("should serialize XML Sequence with xmlNamespace", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy({ stringifyXML: (obj) => JSON.stringify(obj) }), { + phase: "Serialize", + }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: ["item1"] }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "Items", + xmlName: "Items", + xmlElementName: "Item", + xmlNamespace: "http://example.com", + xmlNamespacePrefix: "ex", + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + } satisfies SequenceMapper, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + const body = capturedRequest!.body as string; + assert.strictEqual( + body, + JSON.stringify({ Item: ["item1"], $: { "xmlns:ex": "http://example.com" } }), + ); + }); + + it("should serialize XML with xmlNamespace on non-Composite/Sequence/Dictionary type", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy({ stringifyXML: (obj) => JSON.stringify(obj) }), { + phase: "Serialize", + }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: "stringValue" }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "Value", + xmlName: "Value", + xmlNamespace: "http://example.com", + type: { name: "String" }, + }, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + const body = capturedRequest!.body as string; + assert.strictEqual( + body, + JSON.stringify({ _: "stringValue", $: { xmlns: "http://example.com" } }), + ); + }); + + it("should handle serialization error in request body", async () => { + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => + Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }), + }, + pipeline, + }); + + await expect( + client.sendOperationRequest( + { body: "not a number" }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + serializer: createSerializer(), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + required: true, + type: { name: "Number" }, + }, + }, + responses: { 200: {} }, + }, + ), + ).rejects.toThrow(/occurred in serializing the payload/); + }); + + it("should handle nullable body being null", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: null }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + serializer: createSerializer(), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + nullable: true, + type: { name: "String" }, + }, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + assert.strictEqual(capturedRequest?.body, "null"); + }); + + it("should serialize Stream body without JSON.stringify in non-XML", async () => { + const streamBody = { pipe: vi.fn(), on: vi.fn() } as unknown as NodeJS.ReadableStream; + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: streamBody }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + serializer: createSerializer(), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + type: { name: "Stream" }, + }, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + assert.strictEqual(capturedRequest?.body, streamBody); + }); +}); + +describe("serializationPolicy - prepareXMLRootList non-array", () => { + it("should serialize XML Sequence without namespace (prepareXMLRootList no-namespace path)", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + serializationPolicy({ + stringifyXML: (obj) => JSON.stringify(obj), + }), + { phase: "Serialize" }, + ); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + // Sequence without xmlNamespace to hit the !xmlNamespaceKey || !xmlNamespace path in prepareXMLRootList + await client.sendOperationRequest( + { body: ["item1", "item2"] }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "Items", + xmlName: "Items", + xmlElementName: "Item", + // No xmlNamespace + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + } satisfies SequenceMapper, + }, + responses: { 200: {} }, + }, + ); + + assert.isDefined(capturedRequest, "Expected request to be captured"); + const parsed = JSON.parse(capturedRequest.body as string); + assert.isArray(parsed.Item); + }); + + it("should wrap non-array value in prepareXMLRootList when body is null", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + serializationPolicy({ + stringifyXML: (obj) => JSON.stringify(obj), + }), + { phase: "Serialize" }, + ); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + // nullable Sequence with null body: serializer returns null (not an array), + // which reaches prepareXMLRootList and triggers the !Array.isArray(obj) branch + await client.sendOperationRequest( + { body: null }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "Items", + xmlName: "Items", + xmlElementName: "Item", + nullable: true, + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + } satisfies SequenceMapper, + }, + responses: { 200: {} }, + }, + ); + + assert.isDefined(capturedRequest, "Expected request to be captured"); + const parsed = JSON.parse(capturedRequest.body as string); + // null was wrapped into [null] by prepareXMLRootList + assert.isArray(parsed.Item); + assert.strictEqual(parsed.Item.length, 1); + assert.isNull(parsed.Item[0]); + }); +}); + +describe("serializationPolicy - XML Stream body should not be stringified", () => { + it("should pass stream through in XML mode", async () => { + const streamBody = { pipe: vi.fn(), on: vi.fn() } as unknown as NodeJS.ReadableStream; + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy( + serializationPolicy({ + stringifyXML: (obj) => JSON.stringify(obj), + }), + { phase: "Serialize" }, + ); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { body: streamBody }, + { + httpMethod: "POST", + baseUrl: "https://example.com", + isXML: true, + contentType: "application/xml", + serializer: createSerializer({}, true), + requestBody: { + parameterPath: "body", + mapper: { + serializedName: "body", + type: { name: "Stream" }, + }, + }, + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + // Stream should not be stringified + assert.strictEqual(capturedRequest?.body, streamBody); + }); +}); + +describe("serializationPolicy - custom headers via requestOptions", () => { + it("should apply custom headers from requestOptions", async () => { + let capturedRequest: OperationRequest | undefined; + const pipeline = createEmptyPipeline(); + pipeline.addPolicy(serializationPolicy(), { phase: "Serialize" }); + const client = new ServiceClient({ + httpClient: { + sendRequest: (req) => { + capturedRequest = req; + return Promise.resolve({ request: req, status: 200, headers: createHttpHeaders() }); + }, + }, + pipeline, + }); + + await client.sendOperationRequest( + { + options: { + requestOptions: { + customHeaders: { "X-Custom": "myValue" }, + }, + }, + }, + { + httpMethod: "GET", + baseUrl: "https://example.com", + serializer: createSerializer(), + responses: { 200: {} }, + }, + ); + + assert.exists(capturedRequest); + assert.strictEqual(capturedRequest?.headers.get("X-Custom"), "myValue"); + }); +}); diff --git a/sdk/core/core-client/test/internal/serviceClient.spec.ts b/sdk/core/core-client/test/internal/serviceClient.spec.ts index affa78b79cf8..90e45a06c0f9 100644 --- a/sdk/core/core-client/test/internal/serviceClient.spec.ts +++ b/sdk/core/core-client/test/internal/serviceClient.spec.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, assert } from "vitest"; +import { describe, it, assert, expect } from "vitest"; import type { CompositeMapper, DictionaryMapper, @@ -25,7 +25,6 @@ import type { PipelinePolicy, PipelineRequest, PipelineResponse, - RestError, SendRequest, } from "@azure/core-rest-pipeline"; import { @@ -69,7 +68,7 @@ describe("ServiceClient", function () { }, }, headerCollectionPrefix: "foo-bar-", - } as DictionaryMapper, + } satisfies DictionaryMapper, }, { parameterPath: "unrelated", @@ -92,9 +91,9 @@ describe("ServiceClient", function () { return { token: "testToken", expiresOnTimestamp: 11111 }; }, }; - try { + expect(() => { let request: OperationRequest; - const client = new ServiceClient({ + new ServiceClient({ httpClient: { sendRequest: (req) => { request = req; @@ -103,15 +102,9 @@ describe("ServiceClient", function () { }, credential, }); - - await client.sendOperationRequest(testOperationArgs, testOperationSpec); - assert.fail(); - } catch (error: any) { - assert.equal( - error.message, - `When using credentials, the ServiceClientOptions must contain either a endpoint or a credentialScopes. Unable to create a bearerTokenAuthenticationPolicy`, - ); - } + }).toThrow( + `When using credentials, the ServiceClientOptions must contain either a endpoint or a credentialScopes. Unable to create a bearerTokenAuthenticationPolicy`, + ); }); it("should use baseUrl to build scope", async function () { @@ -138,7 +131,7 @@ describe("ServiceClient", function () { await client.sendOperationRequest(testOperationArgs, testOperationSpec); assert(request!); - assert.deepEqual(request!.headers.get("authorization"), "Bearer testToken"); + assert.strictEqual(request!.headers.get("authorization"), "Bearer testToken"); }); it("should use endpoint to build scope", async function () { @@ -165,7 +158,7 @@ describe("ServiceClient", function () { await client.sendOperationRequest(testOperationArgs, testOperationSpec); assert(request!); - assert.deepEqual(request!.headers.get("authorization"), "Bearer testToken"); + assert.strictEqual(request!.headers.get("authorization"), "Bearer testToken"); }); it("should use the provided scope", async function () { @@ -192,7 +185,7 @@ describe("ServiceClient", function () { await client.sendOperationRequest(testOperationArgs, testOperationSpec); assert(request!); - assert.deepEqual(request!.headers.get("authorization"), "Bearer testToken"); + assert.strictEqual(request!.headers.get("authorization"), "Bearer testToken"); }); }); @@ -242,7 +235,7 @@ describe("ServiceClient", function () { }, }, headerCollectionPrefix: "foo-bar-", - } as DictionaryMapper, + } satisfies DictionaryMapper, }, { parameterPath: "unrelated", @@ -303,7 +296,7 @@ describe("ServiceClient", function () { }); it("should call onResponse with the full response when encountering an unknown status", async function () { - let request: OperationRequest; + let request: OperationRequest | undefined; const pipeline = createEmptyPipeline(); pipeline.addPolicy(deserializationPolicy()); @@ -322,13 +315,11 @@ describe("ServiceClient", function () { }); let rawResponse: FullOperationResponse | undefined; - let requestFailed = false; - let caughtError: RestError | undefined; let flatResponse: any; let onResponseError: unknown; - try { - await client.sendOperationRequest( + await expect( + client.sendOperationRequest( { options: { onResponse: (response, flat, error) => { @@ -347,19 +338,14 @@ describe("ServiceClient", function () { 200: {}, }, }, - ); - } catch (e: any) { - caughtError = e; - requestFailed = true; - assert.strictEqual(e.name, "RestError"); - } + ), + ).rejects.toMatchObject({ name: "RestError" }); - assert(requestFailed, "Request should fail with unknown status"); - assert(request!); + assert.isDefined(request); assert.strictEqual(rawResponse?.status, 500); assert.strictEqual(rawResponse?.request, request!); assert.deepStrictEqual(flatResponse, { body: undefined }); - assert.strictEqual(caughtError, onResponseError); + assert.isDefined(onResponseError); }); it("should serialize collection:csv query parameters", async function () { @@ -1077,13 +1063,12 @@ describe("ServiceClient", function () { pipeline, }); - try { - await client.sendOperationRequest({}, operationSpec); - assert.fail(); - } catch (ex: any) { - assert.strictEqual(ex.details.errorCode, "InvalidResourceNameHeader"); - assert.strictEqual(ex.details.message, "InvalidResourceNameBody"); - } + await expect(client.sendOperationRequest({}, operationSpec)).rejects.toMatchObject({ + details: { + errorCode: "InvalidResourceNameHeader", + message: "InvalidResourceNameBody", + }, + }); }); it("should deserialize non-streaming default response", async function () { @@ -1157,13 +1142,10 @@ describe("ServiceClient", function () { pipeline, }); - try { - await client.sendOperationRequest({}, operationSpec); - assert.fail(); - } catch (ex: any) { - assert.strictEqual(ex.code, "BlobNotFound"); - assert.strictEqual(ex.message, "The specified blob does not exist."); - } + await expect(client.sendOperationRequest({}, operationSpec)).rejects.toMatchObject({ + code: "BlobNotFound", + message: "The specified blob does not exist.", + }); }); it("should re-use the common instance of DefaultHttpClient", function () { @@ -1264,7 +1246,7 @@ describe("ServiceClient", function () { }, }); const response = await client.sendOperationRequest<{ body: Date }>({}, operationSpec); - assert.isDefined(response.body); + assert.instanceOf(response.body, Date); }); it("should catch the mandatory parameter missing error", async function () { @@ -1336,17 +1318,14 @@ describe("ServiceClient", function () { pipeline, }); - try { - await client.sendOperationRequest( + await expect( + client.sendOperationRequest( { options: undefined, }, operationSpec, - ); - assert.fail("Expected client to throw"); - } catch (error: any) { - assert.include(error.message, "cannot be null or undefined"); - } + ), + ).rejects.toThrow(/cannot be null or undefined/); }); it("should catch the mandatory parameter missing error in the query", async function () { @@ -1420,17 +1399,14 @@ describe("ServiceClient", function () { pipeline, }); - try { - await client.sendOperationRequest( + await expect( + client.sendOperationRequest( { options: undefined, }, operationSpec, - ); - assert.fail("Expected client to throw"); - } catch (error: any) { - assert.include(error.message, "cannot be null or undefined"); - } + ), + ).rejects.toThrow(/cannot be null or undefined/); }); it("should not replace existing queries in request URLs", async function () { diff --git a/sdk/core/core-client/test/internal/urlHelpers.spec.ts b/sdk/core/core-client/test/internal/urlHelpers.spec.ts index ec0d47a3ee3c..59557c70bbd3 100644 --- a/sdk/core/core-client/test/internal/urlHelpers.spec.ts +++ b/sdk/core/core-client/test/internal/urlHelpers.spec.ts @@ -254,3 +254,189 @@ describe("getRequestUrl", function () { ); }); }); + +describe("urlHelpers", () => { + it("should handle triple duplicate query params (array push path)", () => { + const result = appendQueryParams("https://example.com?a=1&a=2&a=3", new Map(), new Set()); + // After parsing, a=1&a=2 becomes array, then a=3 is pushed + assert.include(result, "a=1"); + assert.include(result, "a=2"); + assert.include(result, "a=3"); + }); + + it("should handle sequenceParams with existing scalar value", () => { + const result = appendQueryParams( + "https://example.com?q=existing", + new Map([["q", "newVal"]]), + new Set(["q"]), + false, + ); + // sequenceParams path converts to array, then noOverwrite=false overwrites + assert.include(result, "q=newVal"); + assert.notInclude(result, "q=existing"); + }); + + it("should handle noOverwrite=true to prevent overwriting", () => { + const result = appendQueryParams( + "https://example.com?q=existing", + new Map([["q", "newVal"]]), + new Set(["q"]), + true, + ); + // noOverwrite prevents overwriting; the sequenceParams path creates array but noOverwrite keeps it + assert.include(result, "q=existing"); + assert.include(result, "q=newVal"); + }); + + it("should handle bare query key (undefined value)", () => { + const result = appendQueryParams("https://example.com?foo", new Map(), new Set()); + // bare key "foo" has no = sign, so value is undefined; key is preserved + assert.include(result, "foo"); + }); + + it("should handle existing array + new array merge", () => { + const result = appendQueryParams( + "https://example.com?q=1&q=2", + new Map([["q", ["2", "3"]]]), + new Set(), + ); + assert.include(result, "q=2"); + assert.include(result, "q=3"); + }); + + it("should handle existing array + scalar push", () => { + const result = appendQueryParams( + "https://example.com?q=1&q=2", + new Map([["q", "3"]]), + new Set(), + ); + assert.include(result, "q=1"); + assert.include(result, "q=2"); + assert.include(result, "q=3"); + }); + + it("should handle existing scalar + new array overwrite", () => { + const result = appendQueryParams( + "https://example.com?q=existing", + new Map([["q", ["new1", "new2"]]]), + new Set(), + false, + ); + assert.include(result, "q=new1"); + assert.include(result, "q=new2"); + }); +}); + +describe("urlHelpers - appendPath branches", () => { + it("should handle path with query string attached to path component", () => { + const serializer = createSerializer({}, false); + const url = getRequestUrl( + "https://example.com", + { + path: "/items?extra=1", + httpMethod: "GET", + responses: {}, + serializer, + }, + {}, + {}, + ); + assert.include(url, "extra=1"); + }); + + it("should handle path component that is an absolute URL", () => { + const serializer = createSerializer({}, false); + const url = getRequestUrl( + "https://example.com", + { + path: "/{nextLink}", + httpMethod: "GET", + responses: {}, + urlParameters: [ + { + parameterPath: "nextLink", + mapper: { + serializedName: "nextLink", + required: true, + type: { name: "String" }, + }, + skipEncoding: true, + }, + ], + serializer, + }, + { nextLink: "https://other.com/page2?token=abc" }, + {}, + ); + assert.strictEqual(url, "https://other.com/page2?token=abc"); + }); +}); + +describe("urlHelpers - remaining uncovered lines", () => { + it("should handle empty path in appendPath", () => { + const serializer = createSerializer({}, false); + // operationSpec.path is "{param}" which resolves to "" after replacement + // This causes appendPath to be called with empty pathToAppend + const url = getRequestUrl( + "https://example.com", + { + httpMethod: "GET", + responses: {}, + serializer, + path: "{param}", + urlParameters: [ + { + parameterPath: "param", + mapper: { serializedName: "param", type: { name: "String" } }, + skipEncoding: true, + }, + ], + }, + { param: "" }, + {}, + ); + assert.strictEqual(url, "https://example.com"); + }); + + it("should join path when base URL has no trailing slash", () => { + const serializer = createSerializer({}, false); + const url = getRequestUrl( + "https://example.com/api", + { + path: "items", + httpMethod: "GET", + responses: {}, + serializer, + }, + {}, + {}, + ); + assert.include(url, "api/items"); + }); + + it("should handle undefined value in combinedParams", () => { + // This covers the case where a param has no = sign (bare key) + // simpleParseQueryParams gives value as undefined + // When we later iterate, it hits the else branch at line 307 + const result = appendQueryParams( + "https://example.com?bare", + new Map([["other", "val"]]), + new Set(), + ); + assert.include(result, "bare"); + assert.include(result, "other=val"); + }); + + it("should handle array push in simpleParseQueryParams for 3+ duplicate keys", () => { + // First two dups create an array, third dup pushes to the array + const result = appendQueryParams( + "https://example.com?x=1&x=2&x=3", + new Map([["y", "4"]]), + new Set(), + ); + assert.include(result, "x=1"); + assert.include(result, "x=2"); + assert.include(result, "x=3"); + assert.include(result, "y=4"); + }); +}); diff --git a/sdk/core/core-client/test/internal/utils.spec.ts b/sdk/core/core-client/test/internal/utils.spec.ts new file mode 100644 index 000000000000..e51902fb8007 --- /dev/null +++ b/sdk/core/core-client/test/internal/utils.spec.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert } from "vitest"; +import type { + CompositeMapper, + FullOperationResponse, + OperationResponseMap, +} from "../../src/index.js"; +import { createHttpHeaders, createPipelineRequest } from "@azure/core-rest-pipeline"; +import { flattenResponse } from "../../src/utils.js"; + +const defaultRequest = () => createPipelineRequest({ url: "https://example.com", method: "GET" }); + +describe("flattenResponse", () => { + it("should copy model properties with serializedName into array response", () => { + const fullResponse: FullOperationResponse = { + request: defaultRequest(), + status: 200, + headers: createHttpHeaders(), + parsedBody: Object.assign([1, 2, 3], { nextLink: "https://next" }), + }; + const responseSpec = { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + value: { + serializedName: "", + type: { name: "Sequence", element: { type: { name: "Number" } } }, + }, + nextLink: { + serializedName: "nextLink", + type: { name: "String" }, + }, + }, + }, + } satisfies CompositeMapper, + }; + const result = flattenResponse(fullResponse, responseSpec) as Record; + assert.strictEqual(result.nextLink, "https://next"); + }); + + it("should copy parsedHeaders into array response", () => { + const fullResponse: FullOperationResponse = { + request: defaultRequest(), + status: 200, + headers: createHttpHeaders(), + parsedBody: [1, 2, 3], + parsedHeaders: { "x-custom": "headerVal" }, + }; + const responseSpec = { + bodyMapper: { + type: { + name: "Composite", + modelProperties: { + value: { + serializedName: "", + type: { name: "Sequence", element: { type: { name: "Number" } } }, + }, + }, + }, + } satisfies CompositeMapper, + }; + const result = flattenResponse(fullResponse, responseSpec) as Record; + assert.strictEqual(result["x-custom"], "headerVal"); + }); +}); + +describe("flattenResponse - Stream response", () => { + it("should return stream properties for Stream body type", () => { + const mockStream = { pipe: () => {} } as unknown as NodeJS.ReadableStream; + const fullResponse: FullOperationResponse = { + request: defaultRequest(), + status: 200, + headers: createHttpHeaders(), + readableStreamBody: mockStream, + parsedHeaders: { "x-header": "val" }, + }; + const responseSpec: OperationResponseMap = { + bodyMapper: { + type: { name: "Stream" }, + }, + }; + const result = flattenResponse(fullResponse, responseSpec) as Record; + assert.strictEqual(result.readableStreamBody, mockStream); + assert.strictEqual(result["x-header"], "val"); + }); +}); diff --git a/sdk/core/core-client/test/public/authorizeRequestOnTenantChallenge.spec.ts b/sdk/core/core-client/test/public/authorizeRequestOnTenantChallenge.spec.ts index 221c8e215920..fa1e1d840c6a 100644 --- a/sdk/core/core-client/test/public/authorizeRequestOnTenantChallenge.spec.ts +++ b/sdk/core/core-client/test/public/authorizeRequestOnTenantChallenge.spec.ts @@ -3,7 +3,7 @@ import { describe, it, assert, expect, vi, beforeEach, type Mock } from "vitest"; import type { AccessToken, GetTokenOptions } from "@azure/core-auth"; -import type { PipelineResponse } from "@azure/core-rest-pipeline"; +import type { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; import { bearerTokenAuthenticationPolicy, createHttpHeaders, @@ -102,7 +102,25 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { }, }); - const calledOnce = false; + const nextFn = vi + .fn<(req: PipelineRequest) => Promise>() + .mockImplementationOnce(async (req) => { + assert.equal(req.headers.get("authorization"), "Bearer originalToken"); + return { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize resource_id=https://storage.azure.com`, + }), + request: req, + status: 401, + }; + }) + .mockImplementation(async (req) => { + return { + headers: createHttpHeaders(), + request: req, + status: 200, + }; + }); await policy.sendRequest( { @@ -113,24 +131,7 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { url: "https://example.org", withCredentials: true, }, - async (req) => { - if (!calledOnce) { - assert.equal(req.headers.get("authorization"), "Bearer originalToken"); - return { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize resource_id=https://storage.azure.com`, - }), - request: req, - status: 401, - }; - } - - return { - headers: createHttpHeaders(), - request: req, - status: 200, - }; - }, + nextFn, ); expect(getTokenStub).toBeCalledTimes(2); @@ -147,7 +148,25 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { }, }); - const calledOnce = false; + const nextFn = vi + .fn<(req: PipelineRequest) => Promise>() + .mockImplementationOnce(async (req) => { + assert.equal(req.headers.get("authorization"), "Bearer originalToken"); + return { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize resource_id=https://storage.azure.com`, + }), + request: req, + status: 401, + }; + }) + .mockImplementation(async (req) => { + return { + headers: createHttpHeaders(), + request: req, + status: 200, + }; + }); await policy.sendRequest( { @@ -158,23 +177,7 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { url: "https://example.org", withCredentials: true, }, - async (req) => { - if (!calledOnce) { - assert.equal(req.headers.get("authorization"), "Bearer originalToken"); - return { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize resource_id=https://storage.azure.com`, - }), - request: req, - status: 401, - }; - } - return { - headers: createHttpHeaders(), - request: req, - status: 200, - }; - }, + nextFn, ); expect(getTokenStub).toBeCalledTimes(2); @@ -192,7 +195,25 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { }, }); - const calledOnce = false; + const nextFn = vi + .fn<(req: PipelineRequest) => Promise>() + .mockImplementationOnce(async (req) => { + assert.equal(req.headers.get("authorization"), "Bearer originalToken"); + return { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize`, + }), + request: req, + status: 401, + }; + }) + .mockImplementation(async (req) => { + return { + headers: createHttpHeaders(), + request: req, + status: 200, + }; + }); await policy.sendRequest( { @@ -203,23 +224,7 @@ describe("storageBearerTokenChallengeAuthenticationPolicy", function () { url: "https://example.org", withCredentials: true, }, - async (req) => { - if (!calledOnce) { - assert.equal(req.headers.get("authorization"), "Bearer originalToken"); - return { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer authorization_uri=https://login.microsoftonline.com/${fakeGuid}/oauth2/authorize`, - }), - request: req, - status: 401, - }; - } - return { - headers: createHttpHeaders(), - request: req, - status: 200, - }; - }, + nextFn, ); expect(getTokenStub).toBeCalledTimes(2); diff --git a/sdk/core/core-client/test/public/serializer.spec.ts b/sdk/core/core-client/test/public/serializer.spec.ts index 4d629f4ef976..0b8092b6d2af 100644 --- a/sdk/core/core-client/test/public/serializer.spec.ts +++ b/sdk/core/core-client/test/public/serializer.spec.ts @@ -5,10 +5,14 @@ import { describe, it, assert } from "vitest"; import * as MediaMappers from "../testMappers2.js"; import type { CompositeMapper, + CompositeMapperType, DictionaryMapper, + DictionaryMapperType, EnumMapper, + EnumMapperType, Mapper, SequenceMapper, + SequenceMapperType, } from "../../src/index.js"; import { createSerializer } from "../../src/index.js"; import { Mappers } from "../testMappers1.js"; @@ -116,11 +120,10 @@ describe("Serializer", function () { required: false, serializedName: "Uuid", }; - try { - Serializer.serialize(mapper, invalid_uuid, "uuidBody"); - } catch (error: any) { - assert.match(error.message, /.*with value.*must be of type string and a valid uuid/gi); - } + assert.throws( + () => Serializer.serialize(mapper, invalid_uuid, "uuidBody"), + /.*with value.*must be of type string and a valid uuid/i, + ); }); it("should correctly serialize a number", function () { @@ -140,7 +143,7 @@ describe("Serializer", function () { serializedName: "Boolean", }; const serializedObject = Serializer.serialize(mapper, false, "stringBody"); - assert.equal(serializedObject, false); + assert.strictEqual(serializedObject, false); }); it("should correctly serialize an Enum", function () { @@ -159,14 +162,10 @@ describe("Serializer", function () { required: false, serializedName: "Enum", }; - try { - Serializer.serialize(mapper, 6, "enumBody"); - } catch (error: any) { - assert.match( - error.message, - /6 is not a valid value for enumBody\. The valid values are: \[1,2,3,4\]/gi, - ); - } + assert.throws( + () => Serializer.serialize(mapper, 6, "enumBody"), + /6 is not a valid value for enumBody\. The valid values are: \[1,2,3,4\]/i, + ); }); it("should correctly serialize a ByteArray Object", function () { @@ -355,11 +354,10 @@ describe("Serializer", function () { }, }; const array = [[1], ["2"], [undefined], [1, "2", {}, true, []]]; - try { - Serializer.serialize(mapper, array, mapper.serializedName); - } catch (err: any) { - assert.equal(err.message, "arrayObj cannot be null or undefined."); - } + assert.throws( + () => Serializer.serialize(mapper, array, mapper.serializedName), + /arrayObj cannot be null or undefined\./, + ); }); it("should correctly serialize an array of dictionary of primitives", function () { @@ -1302,7 +1300,7 @@ describe("Serializer", function () { const mapper = Mappers.PetAP; const result = serializer.deserialize(mapper, responseBody, "responseBody"); assert.equal(result.id, 5); - assert.equal(result.status, true); + assert.isTrue(result.status); assert.equal(result.eyeColor, "brown"); assert.equal(result.favoriteFood, "bones"); assert.equal(result.odatalocation, "westus"); @@ -1745,7 +1743,7 @@ describe("Serializer", function () { ); assert.equal(result.content, "justastring"); - assert.equal(result.encoded, undefined); + assert.isUndefined(result.encoded); }); it("should handle xmlIsMsText flag with customized XML_CHARKEY", function () { @@ -2052,7 +2050,7 @@ describe("Serializer", function () { const result = serializer.deserialize(Fish, body, ""); assert.equal(result.siblings.length, 3); - assert(result.siblings[1].picture); + assert.exists(result.siblings[1].picture); assert.equal(result.siblings[2].jawsize, 5); }); @@ -2095,7 +2093,7 @@ describe("Serializer", function () { const result = serializer.serialize(Fish, body, ""); assert.equal(result.siblings.length, 3); - assert(result.siblings[1].picture); + assert.exists(result.siblings[1].picture); assert.equal(result.siblings[2].jawsize, 5); }); }); @@ -2176,3 +2174,1910 @@ describe("Serializer", function () { }); }); }); + +describe("serializer", () => { + const serializer = createSerializer({}, false); + + describe("bufferToBase64Url / base64UrlToByteArray", () => { + it("should serialize Base64Url type with valid Uint8Array", () => { + const result = serializer.serialize( + { type: { name: "Base64Url" }, serializedName: "test" }, + new Uint8Array([1, 2, 3]), + "testObj", + ); + assert.isString(result); + }); + + it("should deserialize Base64Url type", () => { + const result = serializer.deserialize( + { type: { name: "Base64Url" }, serializedName: "test" }, + "AQID", + "testObj", + ); + assert.instanceOf(result, Uint8Array); + }); + + it("should return undefined for falsy Base64Url deserialization", () => { + const result = serializer.deserialize( + { type: { name: "Base64Url" }, serializedName: "test" }, + "", + "testObj", + ); + assert.isUndefined(result); + }); + + it("should return null for null buffer in bufferToBase64Url path", () => { + const result = serializer.serialize( + { type: { name: "Base64Url" }, serializedName: "test" }, + null, + "testObj", + ); + assert.isNull(result); + }); + }); + + describe("serializeBasicTypes", () => { + it("should throw for Number type with non-number value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "Number" }, serializedName: "test" }, + "notANumber", + "testObj", + ), + /must be of type number/, + ); + }); + + it("should throw for String type with non-string value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "String" }, serializedName: "test" }, + 123, + "testObj", + ), + /must be of type string/, + ); + }); + + it("should throw for Boolean type with non-boolean value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "Boolean" }, serializedName: "test" }, + "notBool", + "testObj", + ), + /must be of type boolean/, + ); + }); + + it("should throw for Uuid type with invalid uuid", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "Uuid" }, serializedName: "test" }, + "not-a-uuid", + "testObj", + ), + /must be of type string and a valid uuid/, + ); + }); + + it("should throw for Stream type with invalid stream value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "Stream" }, serializedName: "test" }, + 12345, + "testObj", + ), + /must be a string, Blob, ArrayBuffer/, + ); + }); + + it("should accept a function as Stream type", () => { + const fn = () => {}; + const result = serializer.serialize( + { type: { name: "Stream" }, serializedName: "test" }, + fn, + "testObj", + ); + assert.strictEqual(result, fn); + }); + + it("should accept ArrayBuffer as Stream type", () => { + const buf = new ArrayBuffer(8); + const result = serializer.serialize( + { type: { name: "Stream" }, serializedName: "test" }, + buf, + "testObj", + ); + assert.strictEqual(result, buf); + }); + + it("should accept ArrayBufferView as Stream type", () => { + const view = new Uint8Array(8); + const result = serializer.serialize( + { type: { name: "Stream" }, serializedName: "test" }, + view, + "testObj", + ); + assert.strictEqual(result, view); + }); + }); + + describe("serializeDateTypes", () => { + it("should serialize Date type from Date object", () => { + const d = new Date("2023-06-15T00:00:00Z"); + const result = serializer.serialize( + { type: { name: "Date" }, serializedName: "test" }, + d, + "testObj", + ); + assert.strictEqual(result, "2023-06-15"); + }); + + it("should serialize Date type from string", () => { + const result = serializer.serialize( + { type: { name: "Date" }, serializedName: "test" }, + "2023-06-15", + "testObj", + ); + assert.strictEqual(result, "2023-06-15"); + }); + + it("should throw for Date type with invalid value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "Date" }, serializedName: "test" }, + 12345, + "testObj", + ), + /must be an instanceof Date or a string in ISO8601 format/, + ); + }); + + it("should serialize DateTime type from Date object", () => { + const d = new Date("2023-06-15T10:30:00Z"); + const result = serializer.serialize( + { type: { name: "DateTime" }, serializedName: "test" }, + d, + "testObj", + ); + assert.strictEqual(result, "2023-06-15T10:30:00.000Z"); + }); + + it("should serialize DateTime type from string", () => { + const result = serializer.serialize( + { type: { name: "DateTime" }, serializedName: "test" }, + "2023-06-15T10:30:00Z", + "testObj", + ); + assert.strictEqual(result, "2023-06-15T10:30:00.000Z"); + }); + + it("should throw for DateTime type with invalid value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "DateTime" }, serializedName: "test" }, + {}, + "testObj", + ), + /must be an instanceof Date or a string in ISO8601 format/, + ); + }); + + it("should serialize DateTimeRfc1123 type from Date object", () => { + const d = new Date("2023-06-15T10:30:00Z"); + const result = serializer.serialize( + { type: { name: "DateTimeRfc1123" }, serializedName: "test" }, + d, + "testObj", + ); + assert.strictEqual(result, "Thu, 15 Jun 2023 10:30:00 GMT"); + }); + + it("should serialize DateTimeRfc1123 type from string", () => { + const result = serializer.serialize( + { type: { name: "DateTimeRfc1123" }, serializedName: "test" }, + "Thu, 15 Jun 2023 10:30:00 GMT", + "testObj", + ); + assert.strictEqual(result, "Thu, 15 Jun 2023 10:30:00 GMT"); + }); + + it("should throw for DateTimeRfc1123 type with invalid value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "DateTimeRfc1123" }, serializedName: "test" }, + {}, + "testObj", + ), + /must be an instanceof Date or a string in RFC-1123 format/, + ); + }); + + it("should serialize UnixTime type from Date object", () => { + const d = new Date("2023-06-15T10:30:00Z"); + const result = serializer.serialize( + { type: { name: "UnixTime" }, serializedName: "test" }, + d, + "testObj", + ); + assert.strictEqual(result, Math.floor(d.getTime() / 1000)); + }); + + it("should serialize UnixTime type from date string", () => { + const result = serializer.serialize( + { type: { name: "UnixTime" }, serializedName: "test" }, + "2023-06-15T10:30:00Z", + "testObj", + ); + assert.isNumber(result); + assert.strictEqual(result, Math.floor(new Date("2023-06-15T10:30:00Z").getTime() / 1000)); + }); + + it("should throw for UnixTime type with invalid value", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "UnixTime" }, serializedName: "test" }, + {}, + "testObj", + ), + /must be an instanceof Date or a string/, + ); + }); + + it("should serialize TimeSpan type with valid duration", () => { + const result = serializer.serialize( + { type: { name: "TimeSpan" }, serializedName: "test" }, + "P1D", + "testObj", + ); + assert.strictEqual(result, "P1D"); + }); + + it("should throw for TimeSpan type with invalid duration", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "TimeSpan" }, serializedName: "test" }, + "notADuration", + "testObj", + ), + /must be a string in ISO 8601 format/, + ); + }); + + it("should deserialize UnixTime type", () => { + const result = serializer.deserialize( + { type: { name: "UnixTime" }, serializedName: "test" }, + 1686826200, + "testObj", + ); + assert.instanceOf(result, Date); + }); + + it("should return undefined for falsy UnixTime deserialization", () => { + const result = serializer.deserialize( + { type: { name: "UnixTime" }, serializedName: "test" }, + 0, + "testObj", + ); + assert.isUndefined(result); + }); + }); + + describe("serializeSequenceType", () => { + it("should throw for non-array input", () => { + assert.throws( + () => + serializer.serialize( + { + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + "notAnArray", + "testObj", + ), + /must be of type Array/, + ); + }); + + it("should throw for missing element metadata", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Sequence" } as unknown as SequenceMapperType, + serializedName: "test", + }, + [1, 2], + "testObj", + ), + /element" metadata for an Array must be defined/, + ); + }); + }); + + describe("serializeDictionaryType", () => { + it("should throw for non-object input", () => { + assert.throws( + () => + serializer.serialize( + { + type: { + name: "Dictionary", + value: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies DictionaryMapper, + "notAnObject", + "testObj", + ), + /must be of type object/, + ); + }); + + it("should throw for missing value metadata", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Dictionary" } as unknown as DictionaryMapperType, + serializedName: "test", + }, + { a: 1 }, + "testObj", + ), + /"value" metadata for a Dictionary must be defined/, + ); + }); + }); + + describe("deserializeDictionaryType", () => { + it("should throw for missing value metadata", () => { + assert.throws( + () => + serializer.deserialize( + { + type: { name: "Dictionary" } as unknown as DictionaryMapperType, + serializedName: "test", + }, + { a: 1 }, + "testObj", + ), + /"value" metadata for a Dictionary must be defined/, + ); + }); + }); + + describe("deserializeSequenceType", () => { + it("should throw for missing element metadata", () => { + assert.throws( + () => + serializer.deserialize( + { + type: { name: "Sequence" } as unknown as SequenceMapperType, + serializedName: "test", + }, + [1, 2], + "testObj", + ), + /element" metadata for an Array must be defined/, + ); + }); + + it("should wrap non-array into array (xml2js quirk)", () => { + const result = serializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "Number" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + 42, + "testObj", + ); + assert.deepStrictEqual(result, [42]); + }); + + it("should return falsy responseBody as-is", () => { + const result = serializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "Number" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + null, + "testObj", + ); + assert.isNull(result); + }); + + it("should look up Composite element by className from modelMappers", () => { + const childMapper: CompositeMapper = { + serializedName: "Child", + type: { + name: "Composite", + className: "Child", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }; + const s = createSerializer({ Child: childMapper }, false); + const result = s.deserialize( + { + type: { + name: "Sequence", + element: { + type: { name: "Composite", className: "Child" }, + }, + }, + serializedName: "test", + } satisfies SequenceMapper, + [{ id: 1 }, { id: 2 }], + "testObj", + ); + assert.deepStrictEqual(result, [{ id: 1 }, { id: 2 }]); + }); + }); + + describe("resolveModelProperties / resolveReferencedMapper", () => { + it("should throw when className is not provided", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Composite" } as unknown as CompositeMapperType, + serializedName: "test", + }, + { a: 1 }, + "testObj", + ), + /Class name for model/, + ); + }); + + it("should throw when referenced mapper is not found", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Composite", className: "NonExistent" }, + serializedName: "test", + } satisfies CompositeMapper, + { a: 1 }, + "testObj", + ), + /mapper\(\) cannot be null or undefined/, + ); + }); + + it("should throw when modelProperties are not found on referenced mapper", () => { + const s = createSerializer( + { Broken: { serializedName: "Broken", type: { name: "Composite", className: "Broken" } } }, + false, + ); + assert.throws( + () => + s.serialize( + { + type: { name: "Composite", className: "Broken" }, + serializedName: "test", + } satisfies CompositeMapper, + { a: 1 }, + "testObj", + ), + /modelProperties cannot be null or undefined/, + ); + }); + }); + + describe("serializeCompositeType - additionalProperties", () => { + it("should serialize additionalProperties", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + className: "Test", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + additionalProperties: { type: { name: "String" } }, + }, + }; + const result = serializer.serialize(mapper, { id: 1, extra: "value" }, "testObj"); + assert.strictEqual(result.id, 1); + assert.strictEqual(result.extra, "value"); + }); + + it("should resolve additionalProperties from referenced mapper", () => { + const refMapper: CompositeMapper = { + serializedName: "Ref", + type: { + name: "Composite", + className: "Ref", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + additionalProperties: { type: { name: "String" } }, + }, + }; + const s = createSerializer({ Ref: refMapper }, false); + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + className: "Ref", + }, + }; + const result = s.serialize(mapper, { id: 1, extra: "value" }, "testObj"); + assert.strictEqual(result.id, 1); + assert.strictEqual(result.extra, "value"); + }); + }); + + describe("deserializeCompositeType", () => { + it("should handle headerCollectionPrefix", () => { + const mapper: CompositeMapper = { + serializedName: "Headers", + type: { + name: "Composite", + modelProperties: { + metadata: { + serializedName: "metadata", + type: { + name: "Dictionary", + value: { type: { name: "String" } }, + }, + headerCollectionPrefix: "x-ms-meta-", + } satisfies DictionaryMapper, + }, + }, + }; + const result = serializer.deserialize( + mapper, + { + "x-ms-meta-key1": "val1", + "x-ms-meta-key2": "val2", + other: "ignored", + }, + "testObj", + ); + assert.deepStrictEqual(result.metadata, { key1: "val1", key2: "val2" }); + }); + + it("should handle ignoreUnknownProperties option", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }; + const result = serializer.deserialize(mapper, { id: 1, unknownProp: "hello" }, "testObj", { + xml: {}, + ignoreUnknownProperties: true, + }); + assert.strictEqual(result.id, 1); + assert.isUndefined(result.unknownProp); + }); + + it("should pass through unknown properties when ignoreUnknownProperties is false/default", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + }; + const result = serializer.deserialize(mapper, { id: 1, unknownProp: "hello" }, "testObj"); + assert.strictEqual(result.id, 1); + assert.strictEqual(result.unknownProp, "hello"); + }); + + it("should handle paging deserialization (serializedName === '')", () => { + const mapper: CompositeMapper = { + serializedName: "PagedResult", + type: { + name: "Composite", + modelProperties: { + value: { + serializedName: "", + type: { + name: "Sequence", + element: { type: { name: "Number" } }, + }, + }, + nextLink: { + serializedName: "nextLink", + type: { name: "String" }, + }, + }, + }, + }; + // The paging path checks Array.isArray(responseBody[key]) && serializedName === "" + // responseBody must have a "value" key that is an array + const body = { value: [1, 2, 3], nextLink: "https://next" }; + const result = serializer.deserialize(mapper, body, "testObj"); + assert.deepStrictEqual(Array.from(result), [1, 2, 3]); + assert.strictEqual(result.nextLink, "https://next"); + }); + + it("should handle nested serializedName paths with null intermediate", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + deepValue: { + serializedName: "level1.level2", + type: { name: "String" }, + }, + }, + }, + }; + const result = serializer.deserialize(mapper, { level1: null }, "testObj"); + assert.isUndefined(result.deepValue); + }); + + it("should handle additionalProperties during deserialization", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + additionalProperties: { type: { name: "String" } }, + }, + }; + const result = serializer.deserialize(mapper, { id: 1, extra: "extraVal" }, "testObj"); + assert.strictEqual(result.id, 1); + assert.strictEqual(result.extra, "extraVal"); + }); + }); + + describe("serializeByteArrayType", () => { + it("should throw for non-Uint8Array input", () => { + assert.throws( + () => + serializer.serialize( + { type: { name: "ByteArray" }, serializedName: "test" }, + "notABuffer", + "testObj", + ), + /must be of type Uint8Array/, + ); + }); + }); + + describe("serialize nullable/required", () => { + it("should throw when required and nullable and value is undefined", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "String" }, + serializedName: "test", + required: true, + nullable: true, + }, + undefined, + "testObj", + ), + /cannot be undefined/, + ); + }); + + it("should throw when not required and nullable is false and value is null", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "String" }, + serializedName: "test", + required: false, + nullable: false, + }, + null, + "testObj", + ), + /cannot be null/, + ); + }); + }); + + describe("validateConstraints", () => { + it("should validate ExclusiveMaximum", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { ExclusiveMaximum: 10 }, + }, + 10, + "testObj", + ), + /ExclusiveMaximum/, + ); + }); + + it("should validate ExclusiveMinimum", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { ExclusiveMinimum: 5 }, + }, + 5, + "testObj", + ), + /ExclusiveMinimum/, + ); + }); + + it("should validate InclusiveMaximum", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { InclusiveMaximum: 10 }, + }, + 11, + "testObj", + ), + /InclusiveMaximum/, + ); + }); + + it("should validate InclusiveMinimum", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { InclusiveMinimum: 5 }, + }, + 4, + "testObj", + ), + /InclusiveMinimum/, + ); + }); + + it("should validate MaxItems", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Sequence", element: { type: { name: "String" } } }, + serializedName: "test", + constraints: { MaxItems: 2 }, + }, + [1, 2, 3], + "testObj", + ), + /MaxItems/, + ); + }); + + it("should validate MinItems", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Sequence", element: { type: { name: "String" } } }, + serializedName: "test", + constraints: { MinItems: 2 }, + }, + [1], + "testObj", + ), + /MinItems/, + ); + }); + + it("should validate MaxLength", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "String" }, + serializedName: "test", + constraints: { MaxLength: 3 }, + }, + "abcd", + "testObj", + ), + /MaxLength/, + ); + }); + + it("should validate MinLength", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "String" }, + serializedName: "test", + constraints: { MinLength: 3 }, + }, + "ab", + "testObj", + ), + /MinLength/, + ); + }); + + it("should validate MultipleOf", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { MultipleOf: 3 }, + }, + 7, + "testObj", + ), + /MultipleOf/, + ); + }); + + it("should validate Pattern", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "String" }, + serializedName: "test", + constraints: { Pattern: /^[a-z]+$/ }, + }, + "ABC123", + "testObj", + ), + /Pattern/, + ); + }); + + it("should validate UniqueItems", () => { + assert.throws( + () => + serializer.validateConstraints( + { + type: { name: "Sequence", element: { type: { name: "Number" } } }, + serializedName: "test", + constraints: { UniqueItems: true }, + }, + [1, 2, 2], + "testObj", + ), + /UniqueItems/, + ); + }); + + it("should not validate constraints for null/undefined values", () => { + // Should not throw + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { InclusiveMaximum: 10 }, + }, + null, + "testObj", + ); + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { InclusiveMaximum: 10 }, + }, + undefined, + "testObj", + ); + }); + + it("should pass when value satisfies all constraints", () => { + // Should not throw + serializer.validateConstraints( + { + type: { name: "Number" }, + serializedName: "test", + constraints: { InclusiveMinimum: 1, InclusiveMaximum: 10, MultipleOf: 3 }, + }, + 9, + "testObj", + ); + }); + }); + + describe("serializeEnumType", () => { + it("should throw for missing allowedValues", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Enum" } as unknown as EnumMapperType, + serializedName: "test", + }, + "value", + "testObj", + ), + /Please provide a set of allowedValues/, + ); + }); + + it("should throw for value not in allowedValues", () => { + assert.throws( + () => + serializer.serialize( + { + type: { name: "Enum", allowedValues: ["a", "b"] }, + serializedName: "test", + }, + "c", + "testObj", + ), + /is not a valid value/, + ); + }); + }); + + describe("XML serialization - sequence element xmlNamespace", () => { + const xmlSerializer = createSerializer({}, true); + + it("should add xmlns to Composite element in XML sequence", () => { + const mapper: SequenceMapper = { + serializedName: "Items", + type: { + name: "Sequence", + element: { + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + }, + }, + xmlNamespace: "http://example.com", + xmlNamespacePrefix: "ex", + } satisfies CompositeMapper, + }, + }; + const result = xmlSerializer.serialize(mapper, [{ id: 1 }], "testObj"); + assert.deepStrictEqual(result[0].$, { "xmlns:ex": "http://example.com" }); + }); + + it("should add xmlns to non-Composite element in XML sequence", () => { + const mapper: SequenceMapper = { + serializedName: "Items", + type: { + name: "Sequence", + element: { + type: { name: "String" }, + xmlNamespace: "http://example.com", + serializedName: "item", + }, + }, + }; + const result = xmlSerializer.serialize(mapper, ["hello"], "testObj"); + assert.strictEqual(result[0]._, "hello"); + assert.deepStrictEqual(result[0].$, { xmlns: "http://example.com" }); + }); + }); + + describe("XML deserialization - isXML branches", () => { + const xmlSerializer = createSerializer({}, true); + + it("should handle xmlIsAttribute", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + name: { + serializedName: "name", + xmlName: "name", + xmlIsAttribute: true, + type: { name: "String" }, + }, + }, + }, + }; + const result = xmlSerializer.deserialize(mapper, { $: { name: "testValue" } }, "testObj"); + assert.strictEqual(result.name, "testValue"); + }); + + it("should handle xmlIsMsText with xmlCharKey", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + content: { + serializedName: "content", + xmlName: "content", + xmlIsMsText: true, + type: { name: "String" }, + }, + }, + }, + }; + const result = xmlSerializer.deserialize(mapper, { _: "textContent" }, "testObj"); + assert.strictEqual(result.content, "textContent"); + }); + + it("should handle xmlIsMsText with string responseBody", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + content: { + serializedName: "content", + xmlName: "content", + xmlIsMsText: true, + type: { name: "String" }, + }, + }, + }, + }; + const result = xmlSerializer.deserialize(mapper, "directString", "testObj"); + assert.strictEqual(result.content, "directString"); + }); + + it("should handle xmlIsWrapped", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + items: { + serializedName: "items", + xmlName: "Items", + xmlElementName: "Item", + xmlIsWrapped: true, + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + }, + }, + }, + }; + const result = xmlSerializer.deserialize(mapper, { Items: { Item: ["a", "b"] } }, "testObj"); + assert.deepStrictEqual(result.items, ["a", "b"]); + }); + + it("should handle xmlIsWrapped with missing wrapped element", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + items: { + serializedName: "items", + xmlName: "Items", + xmlElementName: "Item", + xmlIsWrapped: true, + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + }, + }, + }, + }; + const result = xmlSerializer.deserialize(mapper, { Items: {} }, "testObj"); + assert.deepStrictEqual(result.items, []); + }); + + it("should serialize xmlIsAttribute in Composite", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + name: { + serializedName: "name", + xmlName: "name", + xmlIsAttribute: true, + type: { name: "String" }, + }, + }, + }, + }; + const result = xmlSerializer.serialize(mapper, { name: "testValue" }, "testObj"); + assert.deepStrictEqual(result.$, { name: "testValue" }); + }); + + it("should serialize xmlIsWrapped in Composite", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + items: { + serializedName: "items", + xmlName: "Items", + xmlElementName: "Item", + xmlIsWrapped: true, + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + }, + }, + }, + }; + const result = xmlSerializer.serialize(mapper, { items: ["a", "b"] }, "testObj"); + assert.deepStrictEqual(result.Items, { Item: ["a", "b"] }); + }); + }); + + describe("deserialize - XML body with $ and _ keys", () => { + const xmlSerializer = createSerializer({}, true); + + it("should reduce responseBody to xmlCharKey when both $ and _ present", () => { + const result = xmlSerializer.deserialize( + { type: { name: "String" }, serializedName: "test" }, + { $: { attr: "val" }, _: "bodyContent" }, + "testObj", + ); + assert.strictEqual(result, "bodyContent"); + }); + }); + + describe("deserialize - Boolean strings", () => { + it("should parse 'true' string as boolean true", () => { + const result = serializer.deserialize( + { type: { name: "Boolean" }, serializedName: "test" }, + "true", + "testObj", + ); + assert.strictEqual(result, true); + }); + + it("should parse 'false' string as boolean false", () => { + const result = serializer.deserialize( + { type: { name: "Boolean" }, serializedName: "test" }, + "false", + "testObj", + ); + assert.strictEqual(result, false); + }); + + it("should return raw boolean value", () => { + const result = serializer.deserialize( + { type: { name: "Boolean" }, serializedName: "test" }, + true, + "testObj", + ); + assert.strictEqual(result, true); + }); + }); + + describe("deserialize - Number", () => { + it("should return non-numeric string as-is when deserializing Number type", () => { + const result = serializer.deserialize( + { type: { name: "Number" }, serializedName: "test" }, + "notANumber", + "testObj", + ); + assert.strictEqual(result, "notANumber"); + }); + }); + + describe("deserialize - Date types", () => { + it("should deserialize Date type", () => { + const result = serializer.deserialize( + { type: { name: "Date" }, serializedName: "test" }, + "2023-06-15", + "testObj", + ); + assert.instanceOf(result, Date); + }); + + it("should deserialize DateTime type", () => { + const result = serializer.deserialize( + { type: { name: "DateTime" }, serializedName: "test" }, + "2023-06-15T10:30:00Z", + "testObj", + ); + assert.instanceOf(result, Date); + }); + + it("should deserialize DateTimeRfc1123 type", () => { + const result = serializer.deserialize( + { type: { name: "DateTimeRfc1123" }, serializedName: "test" }, + "Thu, 15 Jun 2023 10:30:00 GMT", + "testObj", + ); + assert.instanceOf(result, Date); + }); + + it("should deserialize ByteArray type", () => { + const result = serializer.deserialize( + { type: { name: "ByteArray" }, serializedName: "test" }, + "AQID", + "testObj", + ); + assert.instanceOf(result, Uint8Array); + }); + }); + + describe("serialize - readOnly property skipping", () => { + it("should skip readOnly properties during serialization", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + id: { serializedName: "id", readOnly: true, type: { name: "Number" } }, + name: { serializedName: "name", type: { name: "String" } }, + }, + }, + }; + const result = serializer.serialize(mapper, { id: 1, name: "test" }, "testObj"); + assert.isUndefined(result.id); + assert.strictEqual(result.name, "test"); + }); + }); + + describe("serialize - nested serializedName paths", () => { + it("should create intermediate objects for nested paths", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + deepProp: { + serializedName: "level1.level2.value", + type: { name: "String" }, + }, + }, + }, + }; + const result = serializer.serialize(mapper, { deepProp: "hello" }, "testObj"); + assert.strictEqual(result.level1.level2.value, "hello"); + }); + }); + + describe("serialize - isConstant", () => { + it("should use defaultValue for isConstant mapper", () => { + const result = serializer.serialize( + { + type: { name: "String" }, + serializedName: "test", + isConstant: true, + defaultValue: "constantValue", + }, + "anyValue", + "testObj", + ); + assert.strictEqual(result, "constantValue"); + }); + }); + + describe("deserialize - isConstant", () => { + it("should return defaultValue for isConstant mapper during deserialization", () => { + const result = serializer.deserialize( + { + type: { name: "String" }, + serializedName: "test", + isConstant: true, + defaultValue: "constantValue", + }, + "anyResponseValue", + "testObj", + ); + assert.strictEqual(result, "constantValue"); + }); + }); + + describe("deserialize - defaultValue", () => { + it("should return defaultValue when responseBody is undefined", () => { + const result = serializer.deserialize( + { + type: { name: "String" }, + serializedName: "test", + defaultValue: "defaultVal", + }, + undefined, + "testObj", + ); + assert.strictEqual(result, "defaultVal"); + }); + }); + + describe("XML Sequence edge case - empty list", () => { + const xmlSerializer = createSerializer({}, true); + + it("should return empty array for undefined XML non-wrapped Sequence", () => { + const result = xmlSerializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + undefined, + "testObj", + ); + assert.deepStrictEqual(result, []); + }); + + it("should return defaultValue for wrapped XML Sequence that is undefined", () => { + const result = xmlSerializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + serializedName: "test", + xmlIsWrapped: true, + defaultValue: [], + } satisfies SequenceMapper, + undefined, + "testObj", + ); + assert.deepStrictEqual(result, []); + }); + }); + + describe("serialize - xmlNamespace on Composite", () => { + const xmlSerializer = createSerializer({}, true); + + it("should add xmlNamespace to Composite root", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + xmlNamespace: "http://example.com", + xmlNamespacePrefix: "ex", + type: { + name: "Composite", + modelProperties: { + name: { serializedName: "name", xmlName: "name", type: { name: "String" } }, + }, + }, + }; + const result = xmlSerializer.serialize(mapper, { name: "test" }, "testObj"); + assert.deepStrictEqual(result.$, { "xmlns:ex": "http://example.com" }); + }); + }); + + describe("serialize - Dictionary with xmlNamespace", () => { + const xmlSerializer = createSerializer({}, true); + + it("should add xmlNamespace to Dictionary root", () => { + const mapper: DictionaryMapper = { + serializedName: "Dict", + xmlNamespace: "http://example.com", + type: { + name: "Dictionary", + value: { type: { name: "String" } }, + }, + }; + const result = xmlSerializer.serialize(mapper, { key: "val" }, "testObj"); + assert.deepStrictEqual(result.$, { xmlns: "http://example.com" }); + }); + }); + + describe("getXmlObjectValue", () => { + const xmlSerializer = createSerializer({}, true); + + it("should add xmlns to non-Composite type with xmlNamespace", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + value: { + serializedName: "value", + xmlName: "value", + xmlNamespace: "http://example.com", + type: { name: "String" }, + }, + }, + }, + }; + const result = xmlSerializer.serialize(mapper, { value: "hello" }, "testObj"); + assert.strictEqual(result.value._, "hello"); + assert.deepStrictEqual(result.value.$, { xmlns: "http://example.com" }); + }); + + it("should not duplicate xmlns for Composite type that already has $", () => { + const childMapper: CompositeMapper = { + serializedName: "Child", + type: { + name: "Composite", + className: "Child", + modelProperties: { + id: { serializedName: "id", xmlName: "id", type: { name: "Number" } }, + }, + }, + }; + const s = createSerializer({ Child: childMapper }, true); + const mapper: CompositeMapper = { + serializedName: "Parent", + type: { + name: "Composite", + modelProperties: { + child: { + serializedName: "child", + xmlName: "child", + xmlNamespace: "http://example.com", + type: { + name: "Composite", + className: "Child", + }, + }, + }, + }, + }; + // Serialize with a child that will get $ added via xmlNamespace on parent property + const result = s.serialize(mapper, { child: { id: 1 } }, "testObj"); + assert.ok(result.child); + }); + }); + + describe("polymorphic mapper", () => { + it("should find polymorphic mapper during serialization", () => { + const baseMapper: CompositeMapper = { + serializedName: "Animal", + type: { + name: "Composite", + className: "Animal", + uberParent: "Animal", + polymorphicDiscriminator: { + serializedName: "kind", + clientName: "kind", + }, + modelProperties: { + kind: { serializedName: "kind", type: { name: "String" } }, + }, + }, + }; + const dogMapper: CompositeMapper = { + serializedName: "Dog", + type: { + name: "Composite", + className: "Dog", + uberParent: "Animal", + modelProperties: { + kind: { serializedName: "kind", type: { name: "String" } }, + bark: { serializedName: "bark", type: { name: "Boolean" } }, + }, + }, + }; + const s = createSerializer( + { + Animal: baseMapper, + Dog: dogMapper, + discriminators: { + "Animal.Dog": dogMapper, + }, + }, + false, + ); + const result = s.serialize(baseMapper, { kind: "Dog", bark: true }, "testObj"); + assert.strictEqual(result.kind, "Dog"); + assert.strictEqual(result.bark, true); + }); + + it("should find polymorphic mapper during deserialization", () => { + const baseMapper: CompositeMapper = { + serializedName: "Animal", + type: { + name: "Composite", + className: "Animal", + uberParent: "Animal", + polymorphicDiscriminator: { + serializedName: "kind", + clientName: "kind", + }, + modelProperties: { + kind: { serializedName: "kind", type: { name: "String" } }, + }, + }, + }; + const dogMapper: CompositeMapper = { + serializedName: "Dog", + type: { + name: "Composite", + className: "Dog", + uberParent: "Animal", + modelProperties: { + kind: { serializedName: "kind", type: { name: "String" } }, + bark: { serializedName: "bark", type: { name: "Boolean" } }, + }, + }, + }; + const s = createSerializer( + { + Animal: baseMapper, + Dog: dogMapper, + discriminators: { + "Animal.Dog": dogMapper, + }, + }, + false, + ); + const result = s.deserialize(baseMapper, { kind: "Dog", bark: true }, "testObj"); + assert.strictEqual(result.kind, "Dog"); + assert.strictEqual(result.bark, true); + }); + }); + + describe("splitSerializeName with escaped dots", () => { + it("should handle escaped dots in serializedName", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + dotProp: { + serializedName: "level1\\.level2", + type: { name: "String" }, + }, + }, + }, + }; + const result = serializer.serialize(mapper, { dotProp: "value" }, "testObj"); + assert.strictEqual(result["level1.level2"], "value"); + }); + }); + + describe("Composite serialization - polymorphic discriminator default value", () => { + it("should use mapper serializedName as discriminator value when toSerialize is undefined", () => { + const baseMapper: CompositeMapper = { + serializedName: "BaseType", + type: { + name: "Composite", + className: "BaseType", + uberParent: "BaseType", + polymorphicDiscriminator: { + serializedName: "type", + clientName: "type", + }, + modelProperties: { + type: { serializedName: "type", type: { name: "String" } }, + name: { serializedName: "name", type: { name: "String" } }, + }, + }, + }; + const s = createSerializer( + { + BaseType: baseMapper, + discriminators: {}, + }, + false, + ); + const result = s.serialize(baseMapper, { name: "test" }, "testObj"); + assert.strictEqual(result.type, "BaseType"); + }); + }); + + describe("serialize - Composite with empty object for undefined/null values", () => { + it("should handle null values in Composite", () => { + const mapper: CompositeMapper = { + serializedName: "Test", + type: { + name: "Composite", + modelProperties: { + value: { serializedName: "value", type: { name: "String" } }, + }, + }, + }; + const result = serializer.serialize(mapper, null, "testObj"); + assert.isNull(result); + }); + }); + + describe("getPolymorphicDiscriminatorRecursively - uberParent/className lookup", () => { + it("should look up polymorphicDiscriminator from uberParent", () => { + const parentMapper: CompositeMapper = { + serializedName: "Parent", + type: { + name: "Composite", + className: "Parent", + uberParent: "Parent", + polymorphicDiscriminator: { + serializedName: "type", + clientName: "type", + }, + modelProperties: { + type: { serializedName: "type", type: { name: "String" } }, + }, + }, + }; + const childMapper: CompositeMapper = { + serializedName: "Child", + type: { + name: "Composite", + className: "Child", + uberParent: "Parent", + modelProperties: { + type: { serializedName: "type", type: { name: "String" } }, + extra: { serializedName: "extra", type: { name: "String" } }, + }, + }, + }; + const s = createSerializer( + { + Parent: parentMapper, + Child: childMapper, + discriminators: { "Parent.Child": childMapper }, + }, + false, + ); + const result = s.deserialize(childMapper, { type: "Child", extra: "val" }, "testObj"); + assert.strictEqual(result.extra, "val"); + }); + }); +}); + +describe("serializer - Dictionary deserialization with falsy body", () => { + it("should return falsy responseBody for Dictionary (0)", () => { + const serializer = createSerializer({}, false); + // 0 is falsy but not null/undefined, so it passes the null check at line 233 + // and reaches deserializeDictionaryType which returns it at line 1091 + const result = serializer.deserialize( + { + type: { + name: "Dictionary", + value: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies DictionaryMapper, + 0, + "testObj", + ); + assert.strictEqual(result, 0); + }); + it("should return falsy responseBody for Dictionary (empty string)", () => { + const serializer = createSerializer({}, false); + const result = serializer.deserialize( + { + type: { + name: "Dictionary", + value: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies DictionaryMapper, + "", + "testObj", + ); + assert.strictEqual(result, ""); + }); +}); + +describe("serializer - Sequence deserialization with falsy body", () => { + it("should return falsy responseBody for Sequence (0)", () => { + const serializer = createSerializer({}, false); + const result = serializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + 0, + "testObj", + ); + assert.strictEqual(result, 0); + }); + it("should return falsy responseBody for Sequence (false)", () => { + const serializer = createSerializer({}, false); + const result = serializer.deserialize( + { + type: { + name: "Sequence", + element: { type: { name: "String" } }, + }, + serializedName: "test", + } satisfies SequenceMapper, + false, + "testObj", + ); + assert.strictEqual(result, false); + }); +}); + +describe("serializer - polymorphic discriminator default during deserialization", () => { + it("should use mapper.serializedName as discriminator when value is missing", () => { + const baseMapper: CompositeMapper = { + serializedName: "Animal", + type: { + name: "Composite", + className: "Animal", + uberParent: "Animal", + polymorphicDiscriminator: { + serializedName: "kind", + clientName: "kind", + }, + modelProperties: { + kind: { serializedName: "kind", type: { name: "String" } }, + name: { serializedName: "name", type: { name: "String" } }, + }, + }, + }; + const s = createSerializer({ Animal: baseMapper, discriminators: {} }, false); + // When kind is not present in the response body, it should default to mapper.serializedName + const result = s.deserialize(baseMapper, { name: "Fido" }, "testObj"); + assert.strictEqual(result.kind, "Animal"); + }); +}); + +describe("serializer - Sequence element className lookup", () => { + it("should look up Composite element by className from modelMappers during serialization", () => { + const childMapper: CompositeMapper = { + serializedName: "Child", + type: { + name: "Composite", + className: "Child", + modelProperties: { + id: { serializedName: "id", type: { name: "Number" } }, + name: { serializedName: "name", type: { name: "String" } }, + }, + }, + }; + const s = createSerializer({ Child: childMapper }, false); + const result = s.serialize( + { + type: { + name: "Sequence", + element: { + type: { name: "Composite", className: "Child" }, + }, + }, + serializedName: "test", + } satisfies SequenceMapper, + [{ id: 1, name: "a" }], + "testObj", + ); + assert.deepStrictEqual(result, [{ id: 1, name: "a" }]); + }); +}); + +describe("serializer - getXmlObjectValue Composite with existing $ attr", () => { + it("should return as-is when Composite already has $ from its own xmlNamespace", () => { + // Child model WITH xmlNamespace - its serialization adds $ to payload + const childModel: CompositeMapper = { + serializedName: "ChildModel", + xmlNamespace: "http://child.com", + xmlNamespacePrefix: "ch", + type: { + name: "Composite", + className: "ChildModel", + modelProperties: { + text: { + serializedName: "text", + xmlName: "text", + type: { name: "String" }, + }, + }, + }, + }; + + const parentMapper: CompositeMapper = { + serializedName: "ParentModel", + type: { + name: "Composite", + modelProperties: { + child: { + serializedName: "child", + xmlName: "child", + xmlNamespace: "http://outer.com", + xmlNamespacePrefix: "outer", + type: { + name: "Composite", + className: "ChildModel", + }, + } satisfies CompositeMapper, + }, + }, + }; + + const s = createSerializer({ ChildModel: childModel }, true); + const result = s.serialize(parentMapper, { child: { text: "hello" } }, "testObj"); + // child should have $ from its own xmlNamespace (line 845 path) + assert.ok(result.child); + assert.ok(result.child.$); + assert.strictEqual(result.child.text, "hello"); + }); + + it("should add xmlns for Composite without existing $ attr", () => { + // Child model with NO properties - so the for loop doesn't execute, + // and $ is never set on the payload by serializeCompositeType + const childModelEmpty: CompositeMapper = { + serializedName: "ChildEmpty", + type: { + name: "Composite", + className: "ChildEmpty", + modelProperties: {}, + }, + }; + + const parentMapper: CompositeMapper = { + serializedName: "ParentModel2", + type: { + name: "Composite", + modelProperties: { + child: { + serializedName: "child", + xmlName: "child", + xmlNamespace: "http://outer.com", + type: { + name: "Composite", + className: "ChildEmpty", + }, + } satisfies CompositeMapper, + }, + }, + }; + + const s = createSerializer({ ChildEmpty: childModelEmpty }, true); + const result = s.serialize(parentMapper, { child: {} }, "testObj"); + // getXmlObjectValue adds $ since child didn't have it (lines 847-849) + assert.ok(result.child); + assert.ok(result.child.$); + assert.strictEqual(result.child.$["xmlns"], "http://outer.com"); + }); + + it("should wrap non-Composite value with xmlNamespace", () => { + // A non-Composite property (e.g., String) with xmlNamespace + // goes through the non-Composite path in getXmlObjectValue + const mapper: CompositeMapper = { + serializedName: "Parent", + type: { + name: "Composite", + modelProperties: { + value: { + serializedName: "value", + xmlName: "value", + xmlNamespace: "http://ns.com", + xmlNamespacePrefix: "ns", + type: { name: "String" }, + }, + }, + }, + }; + + const s = createSerializer({}, true); + const result = s.serialize(mapper, { value: "hello" }, "testObj"); + // The String value should be wrapped: { _: "hello", $: { "xmlns:ns": "http://ns.com" } } + assert.ok(result.value); + assert.ok(result.value.$); + assert.strictEqual(result.value.$["xmlns:ns"], "http://ns.com"); + assert.strictEqual(result.value._, "hello"); + }); +}); diff --git a/sdk/core/core-client/test/utils/serviceClient.ts b/sdk/core/core-client/test/utils/serviceClient.ts index d1b77c7ae817..7119b96e4575 100644 --- a/sdk/core/core-client/test/utils/serviceClient.ts +++ b/sdk/core/core-client/test/utils/serviceClient.ts @@ -2,12 +2,7 @@ // Licensed under the MIT License. import { assert } from "vitest"; -import type { - FullOperationResponse, - OperationRequest, - OperationResponseMap, - Serializer, -} from "../../src/index.js"; +import type { FullOperationResponse, OperationResponseMap, Serializer } from "../../src/index.js"; import { ServiceClient, createSerializer, deserializationPolicy } from "../../src/index.js"; import type { HttpClient, HttpHeaders, HttpMethods } from "@azure/core-rest-pipeline"; import { createEmptyPipeline, createHttpHeaders } from "@azure/core-rest-pipeline"; @@ -34,12 +29,10 @@ export async function assertServiceClientResponse( testSpec: ServiceClientTestSpec, expectedResponse: unknown, ): Promise { - let request: OperationRequest; const httpClient: HttpClient = { sendRequest: (req) => { - request = req; return Promise.resolve({ - request, + request: req, status: 200, headers: testSpec.responseHeaders ?? createHttpHeaders(), bodyAsText: testSpec.responseBodyAsText,