752 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|