From 858e8cc25de12731ce0a286d68e8b88b24768abf Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 19 Aug 2026 00:02:02 +0200 Subject: [PATCH] feat(azure): add local directory mock Use a seeded in-memory Azure directory when Graph credentials are absent, while keeping the production adapter lazy and unchanged for configured environments. Add tRPC route coverage and run the test suite in CI. --- .env.example | 2 + .github/workflows/test.yaml | 4 +- README.md | 6 +- package.json | 1 + src/azure/client.ts | 42 +++++----- src/azure/config.ts | 25 ++++++ src/azure/directory.ts | 21 +++++ src/azure/functions/emails.ts | 3 +- src/azure/functions/groups.ts | 11 ++- src/azure/functions/members.ts | 19 ++--- src/azure/mock-directory.ts | 140 +++++++++++++++++++++++++++++++++ src/azure/types.ts | 23 ++++++ src/env.ts | 6 +- src/routers/azure/groups.ts | 8 +- src/routers/azure/members.ts | 8 +- src/server.ts | 12 +-- tests/azure-router.test.ts | 95 ++++++++++++++++++++++ 17 files changed, 369 insertions(+), 57 deletions(-) create mode 100644 src/azure/config.ts create mode 100644 src/azure/directory.ts create mode 100644 src/azure/mock-directory.ts create mode 100644 tests/azure-router.test.ts diff --git a/.env.example b/.env.example index ab20f93..48b4ea2 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ ENCRYPTION_KEY="" # you generate it with `openssl rand -hex 20` BETTER_AUTH_SECRET="" +# Optional. Omit all three to use the seeded in-memory Azure directory. +# Set all three to connect to Microsoft Graph. AZURE_TENANT_ID="" AZURE_CLIENT_ID="" AZURE_CLIENT_SECRET="" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 005f5b0..98018c1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,7 +8,7 @@ on: jobs: test: - name: Typecheck and Lint + name: Typecheck, test, and lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -17,5 +17,7 @@ jobs: run: bun install --frozen-lockfile - name: Typecheck run: bun run typecheck + - name: Run tests + run: bun run test - name: Run Biome run: bun run biome ci . diff --git a/README.md b/README.md index be46ef3..5a90897 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ Requirements: - Bun installed - A Postgres database -- An Azure App Registration > [!NOTE] -> You can skip Azure by making `AZURE_*` env vars optional in `./src/env.ts` -> and by removing all Azure related auth in `./src/azure/` +> Azure credentials are optional. When `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and +> `AZURE_CLIENT_SECRET` are all omitted, the Azure tRPC routes use a seeded in-memory directory. +> Its changes last until the backend restarts. Set all three variables to connect to Microsoft Graph. 1. Install packages ```sh diff --git a/package.json b/package.json index 9bf2e5f..74e3abb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:npm": "NODE_ENV=production tsup --config tsup.npm.config.ts", "email": "email dev --port 3012 --dir src/emails/templates", "start": "bun run ./dist/server.js", + "test": "vitest run", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", diff --git a/src/azure/client.ts b/src/azure/client.ts index 24e87e9..88be846 100644 --- a/src/azure/client.ts +++ b/src/azure/client.ts @@ -1,28 +1,28 @@ import { ClientSecretCredential } from "@azure/identity" -import { Client, type GraphError } from "@microsoft/microsoft-graph-client" +import { Client } from "@microsoft/microsoft-graph-client" import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js" -import type { Organization } from "@microsoft/microsoft-graph-types" -import { env } from "@/env" import { logger } from "@/logger" +import { azureCredentials } from "./config" -const credentials = new ClientSecretCredential(env.AZURE_TENANT_ID, env.AZURE_CLIENT_ID, env.AZURE_CLIENT_SECRET) +let client: Client | undefined -const authProvider = new TokenCredentialAuthenticationProvider(credentials, { - // the scopes are configured directly on the App Registration - // this is required by the flow to obtain those scopes - // https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=typescript#using-a-client-certificate-5 - scopes: ["https://graph.microsoft.com/.default"], -}) +export function getAzureClient(): Client { + if (client) return client + if (!azureCredentials) throw new Error("Azure Graph cannot be used without credentials") -export const client = Client.initWithMiddleware({ authProvider }) - -// test request -- void because we do not want to wait -void client - .api(`/organization/${env.AZURE_TENANT_ID}`) - .get() - .then((r: Organization) => logger.info({ orgName: r.displayName }, "[Azure Graph API] Client connected successfully")) - .catch((e: GraphError) => { - if (e.code === "AuthenticationRequiredError") - logger.error({ error: e.message }, "[Azure Graph API] Authentication failed, check credentials") - else logger.error({ error: e }, "[Azure Graph API] Error on TEST request") + const credentials = new ClientSecretCredential( + azureCredentials.tenantId, + azureCredentials.clientId, + azureCredentials.clientSecret + ) + const authProvider = new TokenCredentialAuthenticationProvider(credentials, { + // the scopes are configured directly on the App Registration + // this is required by the flow to obtain those scopes + // https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=typescript#using-a-client-certificate-5 + scopes: ["https://graph.microsoft.com/.default"], }) + + client = Client.initWithMiddleware({ authProvider }) + logger.info("[Azure Graph API] Client initialized") + return client +} diff --git a/src/azure/config.ts b/src/azure/config.ts new file mode 100644 index 0000000..6b8c7d1 --- /dev/null +++ b/src/azure/config.ts @@ -0,0 +1,25 @@ +import { env } from "@/env" + +export type AzureCredentials = { + tenantId: string + clientId: string + clientSecret: string +} + +function resolveAzureCredentials(): AzureCredentials | null { + const values = { + tenantId: env.AZURE_TENANT_ID, + clientId: env.AZURE_CLIENT_ID, + clientSecret: env.AZURE_CLIENT_SECRET, + } + const configuredCount = Object.values(values).filter(Boolean).length + + if (configuredCount === 0) return null + if (configuredCount !== Object.keys(values).length) { + throw new Error("Set AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET together, or omit all three") + } + + return values as AzureCredentials +} + +export const azureCredentials = resolveAzureCredentials() diff --git a/src/azure/directory.ts b/src/azure/directory.ts new file mode 100644 index 0000000..093c7f3 --- /dev/null +++ b/src/azure/directory.ts @@ -0,0 +1,21 @@ +import { logger } from "@/logger" +import { azureCredentials } from "./config" +import { addGroupMember, getAllGroups, removeGroupMember } from "./functions/groups" +import { createMember, getMembers, setMemberNumber } from "./functions/members" +import { createMockAzureDirectory } from "./mock-directory" +import type { AzureDirectory } from "./types" + +const graphAzureDirectory: AzureDirectory = { + getMembers, + setMemberNumber, + createMember, + getAllGroups, + addGroupMember, + removeGroupMember, +} + +export const azureDirectory: AzureDirectory = azureCredentials ? graphAzureDirectory : createMockAzureDirectory() + +if (!azureCredentials) { + logger.warn("Azure credentials are not set, using the seeded in-memory directory") +} diff --git a/src/azure/functions/emails.ts b/src/azure/functions/emails.ts index 0079782..0dc4a1c 100644 --- a/src/azure/functions/emails.ts +++ b/src/azure/functions/emails.ts @@ -3,7 +3,7 @@ import MailComposer from "nodemailer/lib/mail-composer/index.js" import type { JSX } from "react" import { env } from "@/env" import { logger } from "@/logger" -import { client } from "../client" +import { getAzureClient } from "../client" export async function sendEmail(to: string, subject: string, component: JSX.Element) { const html = await render(component) @@ -27,6 +27,7 @@ export async function sendEmail(to: string, subject: string, component: JSX.Elem const base64Encoded = mimeMessage.toString("base64") try { + const client = getAzureClient() await client.api(`/users/${sender}/sendMail`).header("Content-Type", "text/plain").post(base64Encoded) logger.info({ subject, to }, "[Azure Graph API] Email sent") diff --git a/src/azure/functions/groups.ts b/src/azure/functions/groups.ts index d1f3518..821ab78 100644 --- a/src/azure/functions/groups.ts +++ b/src/azure/functions/groups.ts @@ -1,7 +1,7 @@ import type { Group as TGroup, User as TUser } from "@microsoft/microsoft-graph-types" import { logger } from "@/logger" import { withRetry } from "@/utils/wait" -import { client } from "../client" +import { getAzureClient } from "../client" import type { ParsedGroup } from "../types" export type Group = Pick, "id" | "displayName" | "mailNickname" | "mailEnabled"> & { @@ -10,6 +10,7 @@ export type Group = Pick, "id" | "displayName" | "mailNickname" export async function getAllGroups(): Promise { try { + const client = getAzureClient() const res: Group[] = await client .api("/groups?$select=id,displayName,mailNickname,mailEnabled&$expand=members($select=id,displayName)") .get() @@ -28,12 +29,13 @@ export async function getAllGroups(): Promise { export async function addGroupMember(groupId: string, userId: string): Promise { try { - const res = withRetry(() => + const client = getAzureClient() + await withRetry(() => client.api(`/groups/${groupId}/members/$ref`).post({ "@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${userId}`, }) ) - logger.debug({ res, userId, groupId }, "[MS Graph API] OK addGroupMember call") + logger.debug({ userId, groupId }, "[MS Graph API] OK addGroupMember call") return true } catch (error) { logger.error({ error, userId, groupId }, "[MS Graph API] Error in addGroupMember call") @@ -42,7 +44,8 @@ export async function addGroupMember(groupId: string, userId: string): Promise { try { - withRetry(() => client.api(`/groups/${groupId}/members/${userId}/$ref`).delete()) + const client = getAzureClient() + await withRetry(() => client.api(`/groups/${groupId}/members/${userId}/$ref`).delete()) logger.debug({ userId, groupId }, "[MS Graph API] OK removeGroupMember call") return true } catch (error) { diff --git a/src/azure/functions/members.ts b/src/azure/functions/members.ts index 2f14afd..41ec310 100644 --- a/src/azure/functions/members.ts +++ b/src/azure/functions/members.ts @@ -1,8 +1,8 @@ import { logger } from "@/logger" import { generatePassword } from "@/utils/password" import { withRetry } from "@/utils/wait" -import { client } from "../client" -import type { ParsedUser, User } from "../types" +import { getAzureClient } from "../client" +import type { CreatedMember, CreateMemberInput, ParsedUser, User } from "../types" const GruppoSociID = "1c68dbb8-4ac3-4569-a886-283b5a825cbd" const Licenses = { @@ -15,6 +15,7 @@ const FlippedLicenses = Object.fromEntries(Object.entries(Licenses).map(([key, v export async function getMembers(): Promise { try { + const client = getAzureClient() const allPolinetworkUsers: User[] = await client .api(`/users`) .header("ConsistencyLevel", "eventual") @@ -47,6 +48,7 @@ export async function getMembers(): Promise { export async function setMemberNumber(userId: string, assocNumber: number) { try { + const client = getAzureClient() await client.api(`/users/${userId}`).patch({ employeeId: assocNumber.toString(), }) @@ -56,15 +58,8 @@ export async function setMemberNumber(userId: string, assocNumber: number) { } } -export async function createMember({ - firstName, - lastName, - assocNumber, -}: { - firstName: string - lastName: string - assocNumber: number -}) { +export async function createMember({ firstName, lastName, assocNumber }: CreateMemberInput): Promise { + const client = getAzureClient() // TODO: separate steps and add better error handling, maybe with neverthrow const password = generatePassword() const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase() @@ -112,6 +107,7 @@ export async function createMember({ } export async function changePassword(userId: string) { + const client = getAzureClient() const password = generatePassword() await withRetry(() => @@ -130,6 +126,7 @@ export async function manageLicenses( addLicenses: (keyof typeof Licenses)[], removeLicenses: (keyof typeof Licenses)[] ) { + const client = getAzureClient() await withRetry(() => client.api(`/users/${userId}/assignLicense`).post({ addLicenses: addLicenses.map((l) => ({ diff --git a/src/azure/mock-directory.ts b/src/azure/mock-directory.ts new file mode 100644 index 0000000..80b4b75 --- /dev/null +++ b/src/azure/mock-directory.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto" +import { generatePassword } from "@/utils/password" +import type { AzureDirectory, ParsedGroup, ParsedUser } from "./types" + +const ASSOCIATION_GROUP_ID = "mock-group-association-members" + +const SEEDED_MEMBERS: ParsedUser[] = [ + { + id: "mock-member-ada", + displayName: "Ada Lovelace", + mail: "ada.lovelace@polinetwork.org", + givenName: "Ada", + surname: "Lovelace", + employeeId: "1001", + isMember: true, + assignedLicensesIds: ["OFFICE_365", "POWER_AUTOMATE"], + }, + { + id: "mock-member-alan", + displayName: "Alan Turing", + mail: "alan.turing@polinetwork.org", + givenName: "Alan", + surname: "Turing", + employeeId: "1002", + isMember: true, + assignedLicensesIds: ["OFFICE_365"], + }, + { + id: "mock-member-grace", + displayName: "Grace Hopper", + mail: "grace.hopper@polinetwork.org", + givenName: "Grace", + surname: "Hopper", + employeeId: null, + isMember: false, + assignedLicensesIds: [], + }, +] + +const SEEDED_GROUPS: ParsedGroup[] = [ + { + id: ASSOCIATION_GROUP_ID, + displayName: "Local association members", + mailAddress: "local-members@polinetwork.org", + members: [ + { id: "mock-member-ada", displayName: "Ada Lovelace" }, + { id: "mock-member-alan", displayName: "Alan Turing" }, + ], + }, + { + id: "mock-group-volunteers", + displayName: "Local volunteers", + mailAddress: null, + members: [{ id: "mock-member-grace", displayName: "Grace Hopper" }], + }, + { + id: "mock-group-empty", + displayName: "Local empty group", + mailAddress: "local-empty@polinetwork.org", + members: [], + }, +] + +function cloneMembers(members: ParsedUser[]): ParsedUser[] { + return members.map((member) => ({ ...member, assignedLicensesIds: [...member.assignedLicensesIds] })) +} + +function cloneGroups(groups: ParsedGroup[]): ParsedGroup[] { + return groups.map((group) => ({ ...group, members: group.members.map((member) => ({ ...member })) })) +} + +export function createMockAzureDirectory(): AzureDirectory { + const members = cloneMembers(SEEDED_MEMBERS) + const groups = cloneGroups(SEEDED_GROUPS) + + return { + async getMembers() { + return cloneMembers(members) + }, + + async setMemberNumber(userId, assocNumber) { + const member = members.find((candidate) => candidate.id === userId) + if (!member) return { error: `Azure member ${userId} was not found in the local directory` } + + member.employeeId = assocNumber.toString() + return { error: null } + }, + + async createMember({ firstName, lastName, assocNumber }) { + const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase() + const mail = `${mailNickname}@polinetwork.org` + if (members.some((member) => member.mail === mail)) { + throw new Error(`Azure member ${mail} already exists in the local directory`) + } + + const id = `mock-member-${randomUUID()}` + const displayName = `${firstName} ${lastName}` + members.push({ + id, + displayName, + mail, + givenName: firstName, + surname: lastName, + employeeId: assocNumber.toString(), + isMember: true, + assignedLicensesIds: ["OFFICE_365", "POWER_AUTOMATE"], + }) + groups.find((group) => group.id === ASSOCIATION_GROUP_ID)?.members.push({ id, displayName }) + + return { firstName, lastName, id, mail, password: generatePassword() } + }, + + async getAllGroups() { + return cloneGroups(groups) + }, + + async addGroupMember(groupId, userId) { + const group = groups.find((candidate) => candidate.id === groupId) + const member = members.find((candidate) => candidate.id === userId) + if (!group || !member || group.members.some((candidate) => candidate.id === userId)) return false + + group.members.push({ id: member.id, displayName: member.displayName ?? member.mail ?? "Unnamed member" }) + if (group.id === ASSOCIATION_GROUP_ID) member.isMember = true + return true + }, + + async removeGroupMember(groupId, userId) { + const group = groups.find((candidate) => candidate.id === groupId) + const memberIndex = group?.members.findIndex((candidate) => candidate.id === userId) ?? -1 + if (!group || memberIndex === -1) return false + + group.members.splice(memberIndex, 1) + if (group.id === ASSOCIATION_GROUP_ID) { + const member = members.find((candidate) => candidate.id === userId) + if (member) member.isMember = false + } + return true + }, + } +} diff --git a/src/azure/types.ts b/src/azure/types.ts index 69eaaf5..bf06ae6 100644 --- a/src/azure/types.ts +++ b/src/azure/types.ts @@ -42,3 +42,26 @@ export type ParsedGroup = { displayName: string }> } + +export type CreateMemberInput = { + firstName: string + lastName: string + assocNumber: number +} + +export type CreatedMember = { + firstName: string + lastName: string + id: string + mail: string + password: string +} + +export type AzureDirectory = { + getMembers(): Promise + setMemberNumber(userId: string, assocNumber: number): Promise<{ error: string | null }> + createMember(input: CreateMemberInput): Promise + getAllGroups(): Promise + addGroupMember(groupId: string, userId: string): Promise + removeGroupMember(groupId: string, userId: string): Promise +} diff --git a/src/env.ts b/src/env.ts index e001263..3d3762b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -36,9 +36,9 @@ export const env = createEnv({ DB_NAME: z.string().min(3).default("polinetwork_backend"), // Azure - AZURE_TENANT_ID: z.string(), - AZURE_CLIENT_ID: z.string(), - AZURE_CLIENT_SECRET: z.string(), + AZURE_TENANT_ID: z.string().min(1).optional(), + AZURE_CLIENT_ID: z.string().min(1).optional(), + AZURE_CLIENT_SECRET: z.string().min(1).optional(), AZURE_EMAIL_SENDER: z.email().default("noreply@polinetwork.org"), AZURE_BLOB_STORAGE_ACCOUNT: z.string().default("polinetworksa"), AZURE_BLOB_STORAGE_CONTAINER: z.string().default("file-blobs"), diff --git a/src/routers/azure/groups.ts b/src/routers/azure/groups.ts index 3f52b11..2ca0b18 100644 --- a/src/routers/azure/groups.ts +++ b/src/routers/azure/groups.ts @@ -1,19 +1,19 @@ import { z } from "zod" -import { addGroupMember, getAllGroups, removeGroupMember } from "@/azure/functions/groups" +import { azureDirectory } from "@/azure/directory" import { createTRPCRouter, publicProcedure } from "@/trpc" export default createTRPCRouter({ getAll: publicProcedure.query(async () => { - return await getAllGroups() + return await azureDirectory.getAllGroups() }), addMember: publicProcedure .input(z.object({ groupId: z.string(), userId: z.string() })) .mutation(async ({ input }) => { - return await addGroupMember(input.groupId, input.userId) + return await azureDirectory.addGroupMember(input.groupId, input.userId) }), removeMember: publicProcedure .input(z.object({ groupId: z.string(), userId: z.string() })) .mutation(async ({ input }) => { - return await removeGroupMember(input.groupId, input.userId) + return await azureDirectory.removeGroupMember(input.groupId, input.userId) }), }) diff --git a/src/routers/azure/members.ts b/src/routers/azure/members.ts index 2fb4c97..95a853e 100644 --- a/src/routers/azure/members.ts +++ b/src/routers/azure/members.ts @@ -1,12 +1,12 @@ import z from "zod" -import { createMember, getMembers, setMemberNumber } from "@/azure/functions/members" +import { azureDirectory } from "@/azure/directory" import { sendWelcomeEmail } from "@/emails/mailer" import { logger } from "@/logger" import { createTRPCRouter, publicProcedure } from "@/trpc" export default createTRPCRouter({ getAll: publicProcedure.query(async () => { - return await getMembers() + return await azureDirectory.getMembers() }), setAssocNumber: publicProcedure .input( @@ -17,7 +17,7 @@ export default createTRPCRouter({ ) .output(z.object({ error: z.nullable(z.string()) })) .mutation(async ({ input }) => { - const { error } = await setMemberNumber(input.userId, input.assocNumber) + const { error } = await azureDirectory.setMemberNumber(input.userId, input.assocNumber) return { error } }), create: publicProcedure @@ -42,7 +42,7 @@ export default createTRPCRouter({ ) .mutation(async ({ input }) => { try { - const member = await createMember({ + const member = await azureDirectory.createMember({ firstName: input.firstName, lastName: input.lastName, assocNumber: input.assocNumber, diff --git a/src/server.ts b/src/server.ts index a0a0214..7f7472a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,9 +5,8 @@ import { cors } from "hono/cors" import { logger as loggerMiddlware } from "hono/logger" import z from "zod" import { auth } from "./auth" -import { getMembers } from "./azure/functions/members" import "./azure/blob" -import { addGroupMember, getAllGroups, removeGroupMember } from "./azure/functions/groups" +import { azureDirectory } from "./azure/directory" import { AUTH_PATH, TRPC_PATH, WS_PATH } from "./constants" import { cron } from "./cron" import { DB, SCHEMA } from "./db" @@ -84,14 +83,14 @@ app.post( app.get("/test/members", async (c) => { if (env.NODE_ENV === "production") return c.status(500) - const users = await getMembers() + const users = await azureDirectory.getMembers() return c.json({ users }) }) app.get("/test/azure-groups", async (c) => { if (env.NODE_ENV === "production") return c.status(500) - const groups = await getAllGroups() + const groups = await azureDirectory.getAllGroups() return c.json({ groups }) }) @@ -102,7 +101,10 @@ app.post( if (env.NODE_ENV === "production") return c.status(500) const { userId, groupId, mode } = c.req.valid("json") - const ok = mode === "add" ? await addGroupMember(groupId, userId) : await removeGroupMember(groupId, userId) + const ok = + mode === "add" + ? await azureDirectory.addGroupMember(groupId, userId) + : await azureDirectory.removeGroupMember(groupId, userId) return c.json({ ok }) } ) diff --git a/tests/azure-router.test.ts b/tests/azure-router.test.ts new file mode 100644 index 0000000..995488d --- /dev/null +++ b/tests/azure-router.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/emails/mailer", () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue(true), +})) + +const REQUIRED_ENV: Record = { + NODE_ENV: "test", + BETTER_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + DB_HOST: "localhost", + DB_PORT: "5432", + DB_USER: "postgres", + DB_PASS: "postgres", + DB_NAME: "polinetwork_backend_test", +} + +const AZURE_CREDENTIAL_KEYS = ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"] + +async function createCaller() { + for (const [key, value] of Object.entries(REQUIRED_ENV)) process.env[key] = value + for (const key of AZURE_CREDENTIAL_KEYS) delete process.env[key] + + vi.resetModules() + const { azureRouter } = await import("@/routers/azure") + return azureRouter.createCaller({}) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("Azure tRPC routes without credentials", () => { + it("returns linked seed data for members and groups", async () => { + const caller = await createCaller() + + const members = await caller.members.getAll() + const groups = await caller.groups.getAll() + + expect(members).toHaveLength(3) + expect(groups).toHaveLength(3) + expect(members.map((member) => member.id)).toContain("mock-member-ada") + expect(groups.find((group) => group.id === "mock-group-association-members")?.members).toEqual( + expect.arrayContaining([{ id: "mock-member-ada", displayName: "Ada Lovelace" }]) + ) + }) + + it("updates a member number", async () => { + const caller = await createCaller() + + await expect(caller.members.setAssocNumber({ userId: "mock-member-grace", assocNumber: 2042 })).resolves.toEqual({ + error: null, + }) + + const members = await caller.members.getAll() + expect(members.find((member) => member.id === "mock-member-grace")?.employeeId).toBe("2042") + }) + + it("creates a member and exposes it in later reads", async () => { + const caller = await createCaller() + + const result = await caller.members.create({ + firstName: "Katherine", + lastName: "Johnson", + assocNumber: 2043, + sendEmailTo: "developer@example.com", + }) + + expect(result).toMatchObject({ + error: null, + email: "katherine.johnson@polinetwork.org", + welcomeMailSent: true, + }) + if (result.error !== null) throw new Error(result.error) + expect((await caller.members.getAll()).find((member) => member.id === result.id)).toMatchObject({ + displayName: "Katherine Johnson", + employeeId: "2043", + isMember: true, + }) + }) + + it("adds and removes group memberships", async () => { + const caller = await createCaller() + const input = { groupId: "mock-group-empty", userId: "mock-member-grace" } + + await expect(caller.groups.addMember(input)).resolves.toBe(true) + expect((await caller.groups.getAll()).find((group) => group.id === input.groupId)?.members).toContainEqual({ + id: input.userId, + displayName: "Grace Hopper", + }) + + await expect(caller.groups.removeMember(input)).resolves.toBe(true) + expect((await caller.groups.getAll()).find((group) => group.id === input.groupId)?.members).toEqual([]) + }) +})