Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/telephony/services/TelephonyService.server.test.ts
T

752 lines
26 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { CatalogError } from "@hivegrid/foundation";
const mocks = vi.hoisted(() => {
class MockAircallError extends Error {
readonly code: string;
constructor(code: string) {
super(code);
this.code = code;
}
}
class MockAppsEngineServiceError extends Error {}
return {
MockAircallError,
MockAppsEngineServiceError,
rlsTenantIds: [] as string[],
createAuditEvent: vi.fn(),
providerListUsers: vi.fn(),
providerStartCall: vi.fn(),
providerDeleteWebhook: vi.fn(),
checkConnectionHealth: vi.fn(),
disconnectConnection: vi.fn(),
runWithConnectionCredentials: vi.fn(),
aircallOAuthConfigured: vi.fn(),
halloPetraWebhookConfigured: vi.fn(),
tx: {
telephonyConnection: {
findFirst: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
},
dataConnection: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
tenantUser: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
telephonyUserBinding: {
findFirst: vi.fn(),
deleteMany: vi.fn(),
create: vi.fn(),
},
telephonyCallSession: {
findMany: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
},
kontakt: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
inboxLead: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
projekt: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
kontaktTelefon: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
projektKontakt: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
},
};
});
vi.mock("@hivegrid/db/audit-events.server", () => ({
createAuditEvent: mocks.createAuditEvent,
}));
vi.mock("@hivegrid/integrations", () => ({
AircallError: mocks.MockAircallError,
aircallTelephonyProvider: {
listUsers: mocks.providerListUsers,
startCall: mocks.providerStartCall,
deleteWebhook: mocks.providerDeleteWebhook,
},
}));
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
withTenantRLS: (tenantId: string, callback: (tx: typeof mocks.tx) => Promise<unknown>) => {
mocks.rlsTenantIds.push(tenantId);
return callback(mocks.tx);
},
}));
vi.mock("~/utils/integrations/appsEngineService.server", () => ({
AppsEngineServiceError: mocks.MockAppsEngineServiceError,
checkConnectionHealth: mocks.checkConnectionHealth,
disconnectConnection: mocks.disconnectConnection,
runWithConnectionCredentials: mocks.runWithConnectionCredentials,
}));
vi.mock("./AircallConnectionService.server", () => ({
aircallOAuthConfigured: mocks.aircallOAuthConfigured,
}));
vi.mock("./HalloPetraConnectionService.server", () => ({
halloPetraWebhookConfigured: mocks.halloPetraWebhookConfigured,
}));
import {
checkTelephonyConnection,
disconnectTelephonyConnection,
loadProjectTelephonyClickToCallVM,
loadTelephonyCallDetail,
loadTelephonyCalls,
loadTelephonyClickToCallVM,
loadTelephonySettings,
overrideTelephonyCallMatch,
saveTelephonyCallNote,
saveTelephonyUserBinding,
startTelephonyEntityCall,
startTelephonyOutboundCall,
TelephonyServiceError,
} from "./TelephonyService.server";
import type { TelephonyRouteContext } from "../routes/telephonyAccess.server";
const TENANT_ID = "tenant_1";
const USER_ID = "user_1";
const TENANT_USER_ID = "tenant_user_1";
const DATA_CONNECTION_ID = "data_connection_1";
const TELEPHONY_CONNECTION_ID = "telephony_connection_1";
function context(permissions: readonly string[]): TelephonyRouteContext {
return {
tenantId: TENANT_ID,
userId: USER_ID,
tenantUserId: TENANT_USER_ID,
permissions,
hasModule: true,
};
}
function call(overrides: Record<string, unknown> = {}) {
return {
id: "call_1",
createdAt: new Date("2026-07-15T09:59:00.000Z"),
updatedAt: new Date("2026-07-15T10:02:00.000Z"),
tenantId: TENANT_ID,
connectionId: TELEPHONY_CONNECTION_ID,
connection: { provider: "aircall" as const },
providerCallId: "aircall_1",
providerUserId: "provider_user_1",
tenantUserId: TENANT_USER_ID,
direction: "inbound" as const,
status: "completed" as const,
fromNumber: "+49301234567",
toNumber: "+49307654321",
startedAt: new Date("2026-07-15T10:00:00.000Z"),
answeredAt: new Date("2026-07-15T10:00:05.000Z"),
endedAt: new Date("2026-07-15T10:02:00.000Z"),
durationSeconds: 115,
matchedKontaktId: "contact_1",
matchedLeadId: null,
matchedProjektId: null,
matchConfidence: "hard" as const,
manualOverrideAt: null,
manualOverrideUserId: null,
noteMarkdown: "## Ergebnis\nAm Freitag zurückrufen",
recordingUrl: "https://recordings.aircall.test/call_1",
isDemoData: false,
archivedAt: null,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.rlsTenantIds.length = 0;
mocks.aircallOAuthConfigured.mockReturnValue(true);
mocks.halloPetraWebhookConfigured.mockReturnValue(true);
mocks.createAuditEvent.mockResolvedValue(undefined);
mocks.providerListUsers.mockResolvedValue([]);
mocks.providerStartCall.mockResolvedValue({ accepted: true });
mocks.providerDeleteWebhook.mockResolvedValue(undefined);
mocks.checkConnectionHealth.mockResolvedValue({
status: "active",
messageKey: "telephony.health.active",
});
mocks.disconnectConnection.mockResolvedValue(undefined);
mocks.runWithConnectionCredentials.mockImplementation(async (_input: unknown, callback: (credentials: Record<string, unknown>) => Promise<unknown>) =>
callback({ accessToken: "permanent-token" })
);
mocks.tx.telephonyConnection.findFirst.mockResolvedValue(null);
mocks.tx.telephonyConnection.findMany.mockResolvedValue([]);
mocks.tx.telephonyConnection.updateMany.mockResolvedValue({ count: 1 });
mocks.tx.dataConnection.findFirst.mockResolvedValue(null);
mocks.tx.dataConnection.findMany.mockResolvedValue([]);
mocks.tx.tenantUser.findMany.mockResolvedValue([]);
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
mocks.tx.telephonyUserBinding.findFirst.mockResolvedValue(null);
mocks.tx.telephonyUserBinding.deleteMany.mockResolvedValue({ count: 0 });
mocks.tx.telephonyUserBinding.create.mockResolvedValue({ id: "binding_1" });
mocks.tx.telephonyCallSession.findMany.mockResolvedValue([]);
mocks.tx.telephonyCallSession.findFirst.mockResolvedValue(null);
mocks.tx.telephonyCallSession.updateMany.mockResolvedValue({ count: 1 });
mocks.tx.kontakt.findMany.mockResolvedValue([]);
mocks.tx.kontakt.findFirst.mockResolvedValue(null);
mocks.tx.inboxLead.findMany.mockResolvedValue([]);
mocks.tx.inboxLead.findFirst.mockResolvedValue(null);
mocks.tx.projekt.findMany.mockResolvedValue([]);
mocks.tx.projekt.findFirst.mockResolvedValue(null);
mocks.tx.kontaktTelefon.findMany.mockResolvedValue([]);
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue(null);
mocks.tx.projektKontakt.findMany.mockResolvedValue([]);
mocks.tx.projektKontakt.findFirst.mockResolvedValue(null);
});
describe("TelephonyService", () => {
it("builds settings from one tenant slot and merges unmatched provider users", async () => {
mocks.tx.telephonyConnection.findMany.mockResolvedValue([{
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
providerAccountName: "Example Aircall",
webhookId: "webhook_1",
userBindings: [
{
providerUserId: "provider_user_1",
providerEmail: "jane@example.test",
providerDisplayName: "Jane Provider",
defaultProviderNumberId: "number_1",
tenantUserId: TENANT_USER_ID,
source: "email_auto",
},
],
}]);
mocks.tx.dataConnection.findMany.mockResolvedValue([{
id: DATA_CONNECTION_ID,
appId: "aircall",
label: "Aircall",
connectionStatus: "active",
lastHealthCheck: new Date("2026-07-15T09:00:00.000Z"),
healthMessageKey: "telephony.health.active",
}]);
mocks.tx.tenantUser.findMany.mockResolvedValue([
{
id: TENANT_USER_ID,
user: {
email: "jane@example.test",
firstName: "Jane",
lastName: "Doe",
},
},
]);
mocks.providerListUsers.mockResolvedValue([
{
id: "provider_user_1",
name: "Jane Provider",
email: "jane@example.test",
available: true,
availabilityStatus: "available",
defaultNumberId: "number_1",
},
{
id: "provider_user_2",
name: "Unmatched User",
email: "unmatched@example.test",
available: false,
availabilityStatus: null,
defaultNumberId: null,
},
]);
const vm = await loadTelephonySettings(context(["telephony.manage"]), "acme & sons");
expect(vm).toMatchObject({
status: "ready",
data: {
providerConfigured: true,
connectHref: "/integrations/aircall/oauth/install?tenant=acme%20%26%20sons",
connection: {
id: DATA_CONNECTION_ID,
status: "active",
bindings: [
{ id: "provider_user_1", match: "email", hivegridUserId: TENANT_USER_ID },
{ id: "provider_user_2", match: "unmatched", hivegridUserId: null },
],
},
},
});
expect(mocks.runWithConnectionCredentials).toHaveBeenCalledWith(
{
tenantId: TENANT_ID,
connectionId: DATA_CONNECTION_ID,
expectedAppId: "aircall",
},
expect.any(Function)
);
expect(new Set(mocks.rlsTenantIds)).toEqual(new Set([TENANT_ID]));
});
it("limits call-list queries to the bound tenant user without read-all", async () => {
mocks.tx.telephonyCallSession.findMany.mockResolvedValue([call()]);
mocks.tx.tenantUser.findMany.mockResolvedValue([
{
id: TENANT_USER_ID,
user: { email: "jane@example.test", firstName: "Jane", lastName: "Doe" },
},
]);
mocks.tx.kontakt.findMany.mockResolvedValue([
{
id: "contact_1",
kontaktTyp: "privatperson",
vorname: "Clara",
nachname: "Weiss",
orgName: null,
primaerEmail: "clara@example.test",
primaerTelefon: "+49301234567",
},
]);
const vm = await loadTelephonyCalls(context(["telephony.read_own"]), "acme");
expect(mocks.tx.telephonyCallSession.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
tenantId: TENANT_ID,
tenantUserId: TENANT_USER_ID,
}),
})
);
expect(vm).toMatchObject({
status: "ready",
data: {
canReadAll: false,
rows: [
{
id: "call_1",
counterpartLabel: "Clara Weiss",
matchConfidence: "exact",
boundUserLabel: "Jane Doe",
detailHref: "/acme/calls/call_1",
},
],
},
});
});
it("returns the 404 catalog contract for a foreign call detail", async () => {
await expect(loadTelephonyCallDetail(context(["telephony.read_own"]), "acme", "foreign_call")).rejects.toBeInstanceOf(CatalogError);
expect(mocks.tx.telephonyCallSession.findFirst).toHaveBeenCalledWith({
where: {
id: "foreign_call",
tenantId: TENANT_ID,
archivedAt: null,
tenantUserId: TENANT_USER_ID,
},
include: { connection: { select: { provider: true } } },
});
});
it("saves a note only through the caller's tenant visibility and audits metadata", async () => {
await saveTelephonyCallNote(context(["telephony.call"]), {
callId: "call_1",
noteMarkdown: "## Result",
});
expect(mocks.tx.telephonyCallSession.updateMany).toHaveBeenCalledWith({
where: {
id: "call_1",
tenantId: TENANT_ID,
archivedAt: null,
tenantUserId: TENANT_USER_ID,
},
data: { noteMarkdown: "## Result" },
});
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: TENANT_ID,
actorId: USER_ID,
action: "TELEPHONY_CALL_NOTE_SAVED",
metadata: { noteLength: 9 },
})
);
});
it("validates a match in the same tenant before persisting a manual override", async () => {
mocks.tx.telephonyCallSession.findFirst.mockResolvedValue({ id: "call_1" });
mocks.tx.kontakt.findFirst.mockResolvedValue({ id: "contact_2" });
await overrideTelephonyCallMatch(context(["telephony.manage"]), {
callId: "call_1",
match: { type: "contact", id: "contact_2" },
});
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
where: { id: "contact_2", tenantId: TENANT_ID, archivedAt: null },
select: { id: true },
});
expect(mocks.tx.telephonyCallSession.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "call_1", tenantId: TENANT_ID, archivedAt: null },
data: expect.objectContaining({
matchedKontaktId: "contact_2",
matchedLeadId: null,
matchedProjektId: null,
matchConfidence: "manual",
manualOverrideUserId: USER_ID,
}),
})
);
});
it("replaces conflicting mappings with one validated manual binding", async () => {
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
webhookId: "webhook_1",
});
mocks.providerListUsers.mockResolvedValue([
{
id: "provider_user_2",
name: "Jane Provider",
email: "jane@example.test",
available: true,
availabilityStatus: "available",
defaultNumberId: "number_2",
},
]);
mocks.tx.tenantUser.findFirst.mockResolvedValue({ id: TENANT_USER_ID });
await saveTelephonyUserBinding(context(["telephony.manage"]), {
dataConnectionId: DATA_CONNECTION_ID,
providerUserId: "provider_user_2",
tenantUserId: TENANT_USER_ID,
});
expect(mocks.tx.telephonyUserBinding.deleteMany).toHaveBeenCalledWith({
where: {
tenantId: TENANT_ID,
connectionId: TELEPHONY_CONNECTION_ID,
OR: [{ providerUserId: "provider_user_2" }, { tenantUserId: TENANT_USER_ID }],
},
});
expect(mocks.tx.telephonyUserBinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: TENANT_ID,
connectionId: TELEPHONY_CONNECTION_ID,
providerUserId: "provider_user_2",
tenantUserId: TENANT_USER_ID,
source: "manual",
}),
});
});
it("starts click-to-call only with the encrypted connection callback and bound Aircall ids", async () => {
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "aircall" });
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
});
mocks.tx.telephonyUserBinding.findFirst.mockResolvedValue({
providerUserId: "provider_user_1",
defaultProviderNumberId: "number_1",
});
await expect(startTelephonyOutboundCall(context(["telephony.call"]), "+49301234567")).resolves.toEqual({ accepted: true });
expect(mocks.providerStartCall).toHaveBeenCalledWith(
{ accessToken: "permanent-token" },
{
userId: "provider_user_1",
numberId: "number_1",
to: "+49301234567",
}
);
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "TELEPHONY_OUTBOUND_CALL_REQUESTED",
metadata: { providerUserId: "provider_user_1" },
})
);
});
it("normalizes and de-duplicates multiple entity numbers before enabling Aircall", async () => {
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
});
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "aircall" });
mocks.tx.telephonyUserBinding.findFirst.mockResolvedValue({ defaultProviderNumberId: "number_1" });
const vm = await loadTelephonyClickToCallVM(context(["telephony.call"]), [
{ id: "phone_1", label: "Office", e164: "+49 30 1234567", primary: false },
{ id: "phone_2", label: "Primary", e164: "0049 30 1234567", primary: true },
{ id: "phone_3", label: "Mobile", e164: "+491701234567", primary: false },
{ id: "phone_invalid", label: "Invalid", e164: "030 123", primary: false },
]);
expect(vm).toEqual({
mode: "provider",
numbers: [
{ id: "phone_2", label: "Primary", e164: "+49301234567", primary: true },
{ id: "phone_3", label: "Mobile", e164: "+491701234567", primary: false },
],
requiresSelection: true,
reasonKey: null,
});
});
it("keeps valid numbers as tel fallback when the user has no Aircall binding", async () => {
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
});
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "aircall" });
const vm = await loadTelephonyClickToCallVM(context(["telephony.call"]), [{ id: "phone_1", label: "Office", e164: "+49301234567", primary: true }]);
expect(vm).toMatchObject({ mode: "tel-fallback", reasonKey: "telephony.call.fallback.noBinding" });
});
it("keeps HalloPetra inbound-only and never sends click-to-call to its adapter", async () => {
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "hallopetra" });
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "hallopetra",
});
await expect(
loadTelephonyClickToCallVM(context(["telephony.call"]), [
{ id: "phone_1", label: "Office", e164: "+49301234567", primary: true },
]),
).resolves.toMatchObject({
mode: "tel-fallback",
reasonKey: "telephony.call.fallback.providerInboundOnly",
});
await expect(startTelephonyOutboundCall(context(["telephony.call"]), "+49301234567")).rejects.toMatchObject({
messageKey: "telephony.call.fallback.providerInboundOnly",
});
expect(mocks.providerStartCall).not.toHaveBeenCalled();
});
it("loads every valid number from contacts actually linked to the project", async () => {
mocks.tx.projektKontakt.findMany.mockResolvedValue([
{ kontaktId: "contact_primary", primaer: true },
{ kontaktId: "contact_secondary", primaer: false },
]);
mocks.tx.kontaktTelefon.findMany.mockResolvedValue([
{ id: "phone_primary", kontaktId: "contact_primary", telefon: "+49301111111", primaer: true },
{ id: "phone_secondary", kontaktId: "contact_secondary", telefon: "+49302222222", primaer: true },
]);
const vm = await loadProjectTelephonyClickToCallVM(context(["telephony.call"]), {
projectId: "project_1",
contacts: [
{ contactId: "contact_primary", label: "Ada Primary", primary: true },
{ contactId: "contact_secondary", label: "Ben Secondary", primary: false },
{ contactId: "contact_not_linked", label: "Foreign", primary: false },
],
});
expect(mocks.tx.projektKontakt.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
tenantId: TENANT_ID,
projektId: "project_1",
kontaktId: { in: ["contact_primary", "contact_secondary", "contact_not_linked"] },
},
})
);
expect(mocks.tx.kontaktTelefon.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: TENANT_ID, kontaktId: { in: ["contact_primary", "contact_secondary"] } },
})
);
expect(vm.numbers).toEqual([
{ id: "phone_primary", label: "Ada Primary", e164: "+49301111111", primary: true },
{ id: "phone_secondary", label: "Ben Secondary", e164: "+49302222222", primary: false },
]);
});
it("verifies the submitted number against the same-tenant entity before calling Aircall", async () => {
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue({ telefon: "+49 30 1234567" });
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "aircall" });
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
});
mocks.tx.telephonyUserBinding.findFirst.mockResolvedValue({
providerUserId: "provider_user_1",
defaultProviderNumberId: "number_1",
});
await expect(
startTelephonyEntityCall(context(["telephony.call"]), {
entityType: "contact",
entityId: "contact_1",
numberId: "phone_1",
destination: "+49 (30) 1234567",
})
).resolves.toEqual({ accepted: true });
expect(mocks.tx.kontaktTelefon.findFirst).toHaveBeenCalledWith({
where: {
id: "phone_1",
tenantId: TENANT_ID,
kontaktId: "contact_1",
kontakt: { archivedAt: null },
},
select: { telefon: true },
});
expect(mocks.providerStartCall).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ to: "+49301234567" }));
});
it("rejects a tampered entity number before reaching the provider", async () => {
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue({ telefon: "+49301111111" });
await expect(
startTelephonyEntityCall(context(["telephony.call"]), {
entityType: "contact",
entityId: "contact_1",
numberId: "phone_1",
destination: "+49302222222",
})
).rejects.toBeInstanceOf(CatalogError);
expect(mocks.providerStartCall).not.toHaveBeenCalled();
});
it("binds a lead click-to-call request to the lead-owned phone field", async () => {
mocks.tx.inboxLead.findFirst.mockResolvedValue({ telefon: "+49303333333" });
mocks.tx.dataConnection.findFirst.mockResolvedValue({ id: DATA_CONNECTION_ID, appId: "aircall" });
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
});
mocks.tx.telephonyUserBinding.findFirst.mockResolvedValue({
providerUserId: "provider_user_1",
defaultProviderNumberId: "number_1",
});
await startTelephonyEntityCall(context(["telephony.call"]), {
entityType: "lead",
entityId: "lead_1",
numberId: "lead:lead_1",
destination: "+49303333333",
});
expect(mocks.tx.inboxLead.findFirst).toHaveBeenCalledWith({
where: { id: "lead_1", tenantId: TENANT_ID, archivedAt: null },
select: { telefon: true },
});
expect(mocks.providerStartCall).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ to: "+49303333333" }));
});
it("rejects a project number that is not linked to the addressed project", async () => {
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue({
kontaktId: "contact_1",
telefon: "+49304444444",
});
mocks.tx.projektKontakt.findFirst.mockResolvedValue(null);
await expect(
startTelephonyEntityCall(context(["telephony.call"]), {
entityType: "project",
entityId: "project_1",
numberId: "phone_1",
destination: "+49304444444",
})
).rejects.toBeInstanceOf(CatalogError);
expect(mocks.tx.projektKontakt.findFirst).toHaveBeenCalledWith({
where: {
tenantId: TENANT_ID,
projektId: "project_1",
kontaktId: "contact_1",
projekt: { archivedAt: null },
},
select: { id: true },
});
expect(mocks.providerStartCall).not.toHaveBeenCalled();
});
it("delegates health and disconnect lifecycle after tenant-scoped slot lookup", async () => {
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
webhookId: "webhook_1",
});
const ctx = context(["telephony.manage"]);
await checkTelephonyConnection(ctx, "acme", DATA_CONNECTION_ID);
await disconnectTelephonyConnection(ctx, DATA_CONNECTION_ID);
expect(mocks.checkConnectionHealth).toHaveBeenCalledWith({
tenantId: TENANT_ID,
connectionId: DATA_CONNECTION_ID,
notificationDeeplink: "/acme/settings/integrations/telephony",
});
expect(mocks.providerDeleteWebhook).toHaveBeenCalledWith({ accessToken: "permanent-token" }, "webhook_1");
expect(mocks.disconnectConnection).toHaveBeenCalledWith({
tenantId: TENANT_ID,
connectionId: DATA_CONNECTION_ID,
expectedAppId: "aircall",
actorUserId: USER_ID,
});
expect(mocks.tx.telephonyConnection.updateMany).toHaveBeenCalledWith({
where: { id: TELEPHONY_CONNECTION_ID, tenantId: TENANT_ID },
data: { webhookId: null, webhookTokenHash: null },
});
});
it("keeps the local Aircall connection intact when provider webhook deletion fails", async () => {
mocks.tx.telephonyConnection.findFirst.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
provider: "aircall",
webhookId: "webhook_1",
});
mocks.providerDeleteWebhook.mockRejectedValue(new Error("provider unavailable"));
await expect(disconnectTelephonyConnection(context(["telephony.manage"]), DATA_CONNECTION_ID)).rejects.toEqual(
expect.objectContaining<Partial<TelephonyServiceError>>({
messageKey: "telephony.settings.disconnect.providerError",
})
);
expect(mocks.disconnectConnection).not.toHaveBeenCalled();
expect(mocks.tx.telephonyConnection.updateMany).not.toHaveBeenCalled();
});
it("returns a Klartext error instead of calling Aircall for invalid numbers", async () => {
await expect(startTelephonyOutboundCall(context(["telephony.call"]), "030 12345")).rejects.toEqual(
expect.objectContaining<Partial<TelephonyServiceError>>({
messageKey: "telephony.call.error.invalidNumber",
})
);
expect(mocks.runWithConnectionCredentials).not.toHaveBeenCalled();
});
});