From 689bae3634f7831677ffe57700d24604e0356b2f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 6 Oct 2025 13:35:22 -0500 Subject: [PATCH 1/2] Add unit tests --- Makefile | 1 + package.json | 7 +- test/config.test.ts | 194 ++++++++ test/entra.test.ts | 283 ++++++++++++ test/gsuite.test.ts | 755 ++++++++++++++++++++++++++++++ test/sync.test.ts | 374 +++++++++++++++ test/utils.test.ts | 86 ++++ vitest.config.ts | 15 + yarn.lock | 1071 ++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 2781 insertions(+), 5 deletions(-) create mode 100644 test/config.test.ts create mode 100644 test/entra.test.ts create mode 100644 test/gsuite.test.ts create mode 100644 test/sync.test.ts create mode 100644 test/utils.test.ts create mode 100644 vitest.config.ts diff --git a/Makefile b/Makefile index 0a042cf..141490e 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ test_unit: install terraform -chdir=terraform/envs/general init -reconfigure -backend=false -upgrade terraform -chdir=terraform/envs/general fmt -check terraform -chdir=terraform/envs/general validate + yarn test lock_terraform: terraform -chdir=terraform/envs/general providers lock -platform=windows_amd64 -platform=darwin_amd64 -platform=darwin_arm64 -platform=linux_amd64 -platform=linux_arm64 diff --git a/package.json b/package.json index 9ee3199..0eca8ed 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,18 @@ "build": "tsc && node build.js", "lint": "prettier --check *.ts **/*.ts", "prettier:write": "prettier --write *.ts **/*.ts", - "prepare": "husky" + "prepare": "husky", + "test": "vitest --coverage --config ./vitest.config.ts" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/aws-lambda": "^8.10.138", "@types/node": "^24.3.0", + "@vitest/coverage-istanbul": "3.2.4", "esbuild": "^0.25.3", "husky": "^9.1.7", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^3.2.4" }, "dependencies": { "@aws-sdk/client-secrets-manager": "^3.895.0", diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..2b08b7b --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { getConfig, getSecrets } from "../src/config"; +import { + SecretsManagerClient, + GetSecretValueCommand, +} from "@aws-sdk/client-secrets-manager"; + +vi.mock("@aws-sdk/client-secrets-manager"); + +describe("config", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.RunEnvironment = "dev"; + }); + + describe("getSecrets", () => { + it("should return parsed secrets from Secrets Manager", async () => { + const mockSecrets = { + entraTenantId: "tenant-123", + entraClientId: "client-456", + entraClientCertificate: "cert-base64", + googleDelegatedUser: "admin@example.com", + googleServiceAccountJson: '{"type":"service_account"}', + deleteRemovedContacts: true, + }; + + const mockSend = vi.fn().mockResolvedValue({ + SecretString: JSON.stringify(mockSecrets), + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + const result = await getSecrets(); + + expect(result).toEqual(mockSecrets); + expect(mockSend).toHaveBeenCalledWith(expect.any(GetSecretValueCommand)); + }); + + it("should return null if SecretString is empty", async () => { + const mockSend = vi.fn().mockResolvedValue({}); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + const result = await getSecrets(); + + expect(result).toBeNull(); + }); + + it("should return null if JSON parsing fails", async () => { + const mockSend = vi.fn().mockResolvedValue({ + SecretString: "invalid-json", + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + const result = await getSecrets(); + + expect(result).toBeNull(); + }); + }); + + describe("getConfig", () => { + it("should return valid configuration", async () => { + const mockSecrets = { + entraTenantId: "tenant-123", + entraClientId: "client-456", + entraClientCertificate: "cert-base64", + googleDelegatedUser: "admin@example.com", + googleServiceAccountJson: '{"type":"service_account"}', + deleteRemovedContacts: true, + }; + + const mockSend = vi.fn().mockResolvedValue({ + SecretString: JSON.stringify(mockSecrets), + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + const result = await getConfig(); + + expect(result).toEqual({ + ...mockSecrets, + environment: "dev", + }); + }); + + it("should throw error if secrets cannot be loaded", async () => { + const mockSend = vi.fn().mockResolvedValue({}); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + await expect(getConfig()).rejects.toThrow("Failed to load configuration"); + }); + + it("should validate environment is dev or prod", async () => { + process.env.RunEnvironment = "invalid"; + + const mockSecrets = { + entraTenantId: "tenant-123", + entraClientId: "client-456", + entraClientCertificate: "cert-base64", + googleDelegatedUser: "admin@example.com", + googleServiceAccountJson: '{"type":"service_account"}', + deleteRemovedContacts: true, + }; + + const mockSend = vi.fn().mockResolvedValue({ + SecretString: JSON.stringify(mockSecrets), + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + await expect(getConfig()).rejects.toThrow(); + }); + + it("should validate required fields", async () => { + const mockSecrets = { + entraTenantId: "", + entraClientId: "client-456", + entraClientCertificate: "cert-base64", + googleDelegatedUser: "admin@example.com", + googleServiceAccountJson: '{"type":"service_account"}', + }; + + const mockSend = vi.fn().mockResolvedValue({ + SecretString: JSON.stringify(mockSecrets), + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + await expect(getConfig()).rejects.toThrow(); + }); + + it("should default deleteRemovedContacts to true", async () => { + const mockSecrets = { + entraTenantId: "tenant-123", + entraClientId: "client-456", + entraClientCertificate: "cert-base64", + googleDelegatedUser: "admin@example.com", + googleServiceAccountJson: '{"type":"service_account"}', + }; + + const mockSend = vi.fn().mockResolvedValue({ + SecretString: JSON.stringify(mockSecrets), + }); + + vi.mocked(SecretsManagerClient).mockImplementation( + () => + ({ + send: mockSend, + }) as any, + ); + + const result = await getConfig(); + + expect(result.deleteRemovedContacts).toBe(true); + }); + }); +}); diff --git a/test/entra.test.ts b/test/entra.test.ts new file mode 100644 index 0000000..c78cdc9 --- /dev/null +++ b/test/entra.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createEntraClient, + getAllEntraUsers, + getPrimaryEmail, +} from "../src/entra"; +import { Client } from "@microsoft/microsoft-graph-client"; +import { ClientCertificateCredential } from "@azure/identity"; + +vi.mock("@microsoft/microsoft-graph-client"); +vi.mock("@azure/identity"); + +describe("entra", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createEntraClient", () => { + it("should create a Microsoft Graph client with certificate credentials", () => { + const mockInitWithMiddleware = vi.fn(); + vi.mocked(Client.initWithMiddleware).mockReturnValue({} as any); + + const tenantId = "tenant-123"; + const clientId = "client-456"; + const certificate = Buffer.from("cert-content").toString("base64"); + + createEntraClient(tenantId, clientId, certificate); + + expect(ClientCertificateCredential).toHaveBeenCalledWith( + tenantId, + clientId, + { certificate: "cert-content" }, + ); + + expect(Client.initWithMiddleware).toHaveBeenCalled(); + }); + }); + + describe("getAllEntraUsers", () => { + it("should fetch all enabled users from Entra ID", async () => { + const mockUsers = [ + { + userPrincipalName: "john@example.com", + mail: "john@example.com", + givenName: "John", + surname: "Doe", + displayName: "John Doe", + }, + { + userPrincipalName: "jane@example.com", + mail: "jane@example.com", + givenName: "Jane", + surname: "Smith", + displayName: "Jane Smith", + }, + ]; + + const mockGet = vi.fn().mockResolvedValue({ + value: mockUsers, + }); + + const mockClient = { + api: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGet, + }), + } as any; + + const result = await getAllEntraUsers(mockClient); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + email: "john@example.com", + upn: "john@example.com", + givenName: "John", + familyName: "Doe", + displayName: "John Doe", + }); + }); + + it("should handle pagination", async () => { + const mockUsersPage1 = [ + { + userPrincipalName: "john@example.com", + mail: "john@example.com", + givenName: "John", + surname: "Doe", + displayName: "John Doe", + }, + ]; + + const mockUsersPage2 = [ + { + userPrincipalName: "jane@example.com", + mail: "jane@example.com", + givenName: "Jane", + surname: "Smith", + displayName: "Jane Smith", + }, + ]; + + const mockGetPage1 = vi.fn().mockResolvedValue({ + value: mockUsersPage1, + "@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$skip=1", + }); + + const mockGetPage2 = vi.fn().mockResolvedValue({ + value: mockUsersPage2, + }); + + const mockClient = { + api: vi + .fn() + .mockReturnValueOnce({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGetPage1, + }) + .mockReturnValueOnce({ + get: mockGetPage2, + }), + } as any; + + const result = await getAllEntraUsers(mockClient); + + expect(result).toHaveLength(2); + }); + + it("should skip users without email or UPN", async () => { + const mockUsers = [ + { + givenName: "John", + surname: "Doe", + displayName: "John Doe", + }, + { + userPrincipalName: "jane@example.com", + mail: "jane@example.com", + givenName: "Jane", + surname: "Smith", + displayName: "Jane Smith", + }, + ]; + + const mockGet = vi.fn().mockResolvedValue({ + value: mockUsers, + }); + + const mockClient = { + api: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGet, + }), + } as any; + + const result = await getAllEntraUsers(mockClient); + + expect(result).toHaveLength(1); + expect(result[0].email).toBe("jane@example.com"); + }); + + it("should parse display name when given/surname are missing", async () => { + const mockUsers = [ + { + userPrincipalName: "john@example.com", + mail: "john@example.com", + displayName: "John Doe", + }, + ]; + + const mockGet = vi.fn().mockResolvedValue({ + value: mockUsers, + }); + + const mockClient = { + api: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGet, + }), + } as any; + + const result = await getAllEntraUsers(mockClient); + + expect(result[0].givenName).toBe("John"); + expect(result[0].familyName).toBe("Doe"); + }); + + it("should use mail field if UPN is missing", async () => { + const mockUsers = [ + { + mail: "john@example.com", + givenName: "John", + surname: "Doe", + displayName: "John Doe", + }, + ]; + + const mockGet = vi.fn().mockResolvedValue({ + value: mockUsers, + }); + + const mockClient = { + api: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGet, + }), + } as any; + + const result = await getAllEntraUsers(mockClient); + + expect(result[0].email).toBe("john@example.com"); + expect(result[0].upn).toBe(""); + }); + + it("should handle API errors", async () => { + const mockGet = vi.fn().mockRejectedValue(new Error("API Error")); + + const mockClient = { + api: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + filter: vi.fn().mockReturnThis(), + top: vi.fn().mockReturnThis(), + get: mockGet, + }), + } as any; + + await expect(getAllEntraUsers(mockClient)).rejects.toThrow("API Error"); + }); + }); + + describe("getPrimaryEmail", () => { + it("should return email if present", () => { + const user = { + email: "john@example.com", + upn: "john@corp.example.com", + givenName: "John", + familyName: "Doe", + displayName: "John Doe", + }; + + const result = getPrimaryEmail(user); + + expect(result).toBe("john@example.com"); + }); + + it("should return UPN if email is empty", () => { + const user = { + email: "", + upn: "john@corp.example.com", + givenName: "John", + familyName: "Doe", + displayName: "John Doe", + }; + + const result = getPrimaryEmail(user); + + expect(result).toBe("john@corp.example.com"); + }); + + it("should return lowercase email", () => { + const user = { + email: "John@Example.COM", + upn: "john@corp.example.com", + givenName: "John", + familyName: "Doe", + displayName: "John Doe", + }; + + const result = getPrimaryEmail(user); + + expect(result).toBe("john@example.com"); + }); + }); +}); diff --git a/test/gsuite.test.ts b/test/gsuite.test.ts new file mode 100644 index 0000000..4e514ec --- /dev/null +++ b/test/gsuite.test.ts @@ -0,0 +1,755 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createGoogleClient, + getAllDomainContacts, + createDomainContact, + updateDomainContact, + deleteDomainContact, +} from "../src/gsuite"; +import { google } from "googleapis"; + +vi.mock("googleapis"); +global.fetch = vi.fn(); + +describe("gsuite", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createGoogleClient", () => { + it("should create Google Auth client with domain-wide delegation", () => { + const mockGoogleAuth = vi.fn(); + vi.mocked(google.auth.GoogleAuth).mockImplementation( + mockGoogleAuth as any, + ); + + const serviceAccountJson = JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key_id: "key-id", + private_key: + "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n", + client_email: "test@test-project.iam.gserviceaccount.com", + }); + + const delegatedUser = "admin@example.com"; + + createGoogleClient(serviceAccountJson, delegatedUser); + + expect(mockGoogleAuth).toHaveBeenCalledWith({ + credentials: JSON.parse(serviceAccountJson), + scopes: ["https://www.google.com/m8/feeds"], + clientOptions: { + subject: delegatedUser, + }, + }); + }); + }); + + describe("getAllDomainContacts", () => { + it("should fetch all domain shared contacts", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: { + entry: [ + { + id: { + $t: "https://www.google.com/m8/feeds/contacts/example.com/base/contact1", + }, + gd$etag: "etag1", + gd$email: [{ address: "john@illinois.edu", primary: "true" }], + gd$name: { + gd$givenName: { $t: "John" }, + gd$familyName: { $t: "Doe" }, + }, + }, + ], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.size).toBe(1); + expect(result.get("john@illinois.edu")).toEqual({ + id: "contact1", + etag: "etag1", + contact: { + email: "john@illinois.edu", + givenName: "John", + familyName: "Doe", + }, + }); + }); + + it("should handle pagination with multiple pages", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + // Create 1000 contacts for first page + const mockContactsPage1 = { + feed: { + entry: Array.from({ length: 1000 }, (_, i) => ({ + id: { + $t: `https://www.google.com/m8/feeds/contacts/example.com/base/contact${i}`, + }, + gd$etag: `etag${i}`, + gd$email: [{ address: `user${i}@illinois.edu` }], + gd$name: { + gd$givenName: { $t: "User" }, + gd$familyName: { $t: `${i}` }, + }, + })), + }, + }; + + // Empty second page to end pagination + const mockContactsPage2 = { + feed: { + entry: [], + }, + }; + + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockContactsPage1, + } as any) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockContactsPage2, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.size).toBe(1000); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("should handle pagination with partial last page", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContactsPage1 = { + feed: { + entry: Array.from({ length: 500 }, (_, i) => ({ + id: { + $t: `https://www.google.com/m8/feeds/contacts/example.com/base/contact${i}`, + }, + gd$etag: `etag${i}`, + gd$email: [{ address: `user${i}@illinois.edu` }], + gd$name: { + gd$givenName: { $t: "User" }, + gd$familyName: { $t: `${i}` }, + }, + })), + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContactsPage1, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.size).toBe(500); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("should handle API errors", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + text: async () => "Access denied", + } as any); + + await expect( + getAllDomainContacts(mockAuth, "example.com"), + ).rejects.toThrow("Failed to fetch contacts: Forbidden - Access denied"); + }); + + it("should skip entries without email", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: { + entry: [ + { + id: { + $t: "https://www.google.com/m8/feeds/contacts/example.com/base/contact1", + }, + gd$etag: "etag1", + gd$email: [], + gd$name: { + gd$givenName: { $t: "John" }, + gd$familyName: { $t: "Doe" }, + }, + }, + ], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.size).toBe(0); + }); + + it("should handle empty feed", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: {}, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.size).toBe(0); + }); + + it("should use primary email when multiple emails exist", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: { + entry: [ + { + id: { + $t: "https://www.google.com/m8/feeds/contacts/example.com/base/contact1", + }, + gd$etag: "etag1", + gd$email: [ + { address: "secondary@illinois.edu" }, + { address: "primary@illinois.edu", primary: "true" }, + ], + gd$name: { + gd$givenName: { $t: "John" }, + gd$familyName: { $t: "Doe" }, + }, + }, + ], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.get("primary@illinois.edu")).toBeDefined(); + expect(result.get("secondary@illinois.edu")).toBeUndefined(); + }); + + it("should use first email when no primary is specified", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: { + entry: [ + { + id: { + $t: "https://www.google.com/m8/feeds/contacts/example.com/base/contact1", + }, + gd$etag: "etag1", + gd$email: [ + { address: "first@illinois.edu" }, + { address: "second@illinois.edu" }, + ], + gd$name: { + gd$givenName: { $t: "John" }, + gd$familyName: { $t: "Doe" }, + }, + }, + ], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.get("first@illinois.edu")).toBeDefined(); + }); + + it("should normalize email addresses to lowercase", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + const mockContacts = { + feed: { + entry: [ + { + id: { + $t: "https://www.google.com/m8/feeds/contacts/example.com/base/contact1", + }, + gd$etag: "etag1", + gd$email: [{ address: "John.Doe@Illinois.EDU" }], + gd$name: { + gd$givenName: { $t: "John" }, + gd$familyName: { $t: "Doe" }, + }, + }, + ], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockContacts, + } as any); + + const result = await getAllDomainContacts(mockAuth, "example.com"); + + expect(result.get("john.doe@illinois.edu")).toBeDefined(); + expect(result.get("John.Doe@Illinois.EDU")).toBeUndefined(); + }); + }); + + describe("createDomainContact", () => { + it("should create a new domain shared contact", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + } as any); + + const contact = { + email: "john@illinois.edu", + givenName: "John", + familyName: "Doe", + }; + + const result = await createDomainContact( + mockAuth, + "example.com", + contact, + ); + + expect(result).toBe(true); + expect(fetch).toHaveBeenCalledWith( + "https://www.google.com/m8/feeds/contacts/example.com/full", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/atom+xml", + "GData-Version": "3.0", + }), + }), + ); + }); + + it("should include email in XML body", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + } as any); + + const contact = { + email: "john@illinois.edu", + givenName: "John", + familyName: "Doe", + }; + + await createDomainContact(mockAuth, "example.com", contact); + + const callArgs = vi.mocked(fetch).mock.calls[0]; + const body = callArgs[1]?.body as string; + + expect(body).toContain("john@illinois.edu"); + expect(body).toContain("John"); + expect(body).toContain("Doe"); + expect(body).toContain("John Doe"); + }); + + it("should handle creation errors", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "Invalid contact data", + } as any); + + const contact = { + email: "john@illinois.edu", + givenName: "John", + familyName: "Doe", + }; + + const result = await createDomainContact( + mockAuth, + "example.com", + contact, + ); + + expect(result).toBe(false); + }); + + it("should escape XML special characters in names", async () => { + const mockGetClient = vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" }), + }); + + const mockAuth = { + getClient: mockGetClient, + } as any; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + } as any); + + const contact = { + email: "test@example.com", + givenName: 'John', + familyName: "Doe & Associates", + }; + + await createDomainContact(mockAuth, "example.com", contact); + + const callArgs = vi.mocked(fetch).mock.calls[0]; + const body = callArgs[1]?.body as string; + + expect(body).toContain("<script>"); + expect(body).toContain("&"); + expect(body).not.toContain("