Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/appsEngineService.app-binding.test.ts
T

234 lines
7.8 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
connectionFindFirst: vi.fn(),
connectionUpdateMany: vi.fn(async () => ({ count: 1 })),
syncRunCreate: vi.fn(),
syncRunFindMany: vi.fn(async () => []),
syncRunFindFirst: vi.fn(),
syncItemFindMany: vi.fn(async () => []),
syncRunLogFindMany: vi.fn(async () => []),
encryptTenantField: vi.fn(async () => "sealed"),
createAuditEvent: vi.fn(async () => undefined),
notify: vi.fn(async () => undefined),
validateConnection: vi.fn(async () => ({
ok: true,
health: "active" as const,
messageKey: "integrations.health.active",
})),
}));
vi.mock("@hivegrid/foundation", async () => ({
...(await vi.importActual<typeof import("@hivegrid/foundation")>("@hivegrid/foundation")),
notify: mocks.notify,
}));
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
withTenantRLS: async (_tenantId: string, callback: (tx: unknown) => Promise<unknown>) =>
callback({
dataConnection: {
findFirst: mocks.connectionFindFirst,
updateMany: mocks.connectionUpdateMany,
},
appSyncRun: {
create: mocks.syncRunCreate,
findMany: mocks.syncRunFindMany,
findFirst: mocks.syncRunFindFirst,
},
appSyncItem: { findMany: mocks.syncItemFindMany },
appSyncRunLog: { findMany: mocks.syncRunLogFindMany },
}),
}));
vi.mock("@hivegrid/db/crypto/tenant-envelope.server", () => ({
decryptTenantField: vi.fn(),
encryptTenantField: mocks.encryptTenantField,
}));
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: mocks.createAuditEvent }));
vi.mock("~/utils/db.server", () => ({ db: {} }));
vi.mock("@hivegrid/integrations", () => ({
resolveAdapter: vi.fn(() => ({ validateConnection: mocks.validateConnection })),
}));
import {
disconnectConnection,
checkConnectionHealth,
getConnectionOrThrow,
getSyncRunWithItems,
listSyncRuns,
reconnectConnection,
runSyncForConnection,
} from "./appsEngineService.server";
const CONNECTION_ROW = {
id: "connection-1",
tenantId: "tenant-1",
appId: "sample-data-sync",
label: "Sample",
connectionStatus: "active",
lastHealthCheck: null,
healthMessageKey: null,
tokenExpiresAt: null,
createdByUserId: "user-1",
isDemoData: false,
createdAt: new Date("2026-07-20T10:00:00.000Z"),
archivedAt: null,
} as const;
describe("Apps Engine app/connection binding", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.connectionUpdateMany.mockResolvedValue({ count: 1 });
mocks.syncRunFindMany.mockResolvedValue([]);
mocks.syncItemFindMany.mockResolvedValue([]);
mocks.syncRunLogFindMany.mockResolvedValue([]);
mocks.encryptTenantField.mockResolvedValue("sealed");
mocks.validateConnection.mockResolvedValue({
ok: true,
health: "active",
messageKey: "integrations.health.active",
});
});
it("loads a connection only when tenant, id, and expected app all match", async () => {
mocks.connectionFindFirst.mockResolvedValueOnce(CONNECTION_ROW);
await expect(getConnectionOrThrow("tenant-1", "connection-1", "sample-data-sync")).resolves.toMatchObject({
id: "connection-1",
appId: "sample-data-sync",
});
expect(mocks.connectionFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "connection-1", tenantId: "tenant-1", appId: "sample-data-sync" },
}),
);
});
it("binds reconnect and disconnect reads and writes to the expected app", async () => {
mocks.connectionFindFirst.mockResolvedValue({ id: "connection-1", appId: "sample-data-sync" });
await reconnectConnection({
tenantId: "tenant-1",
connectionId: "connection-1",
expectedAppId: "sample-data-sync",
credentials: { apiKey: "fixture" },
actorUserId: "user-1",
});
await disconnectConnection({
tenantId: "tenant-1",
connectionId: "connection-1",
expectedAppId: "sample-data-sync",
actorUserId: "user-1",
});
expect(mocks.connectionFindFirst).toHaveBeenCalledTimes(2);
expect(mocks.connectionFindFirst).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: "connection-1", tenantId: "tenant-1", appId: "sample-data-sync" },
}),
);
expect(mocks.connectionFindFirst).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: "connection-1", tenantId: "tenant-1", appId: "sample-data-sync" },
}),
);
expect(mocks.connectionUpdateMany).toHaveBeenCalledTimes(2);
for (const [input] of mocks.connectionUpdateMany.mock.calls) {
expect(input).toEqual(
expect.objectContaining({
where: { id: "connection-1", tenantId: "tenant-1", appId: "sample-data-sync" },
}),
);
}
expect(mocks.connectionUpdateMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
data: expect.objectContaining({ credentialsCiphertext: null, tokenExpiresAt: null, connectionStatus: "revoked" }),
}),
);
});
it("rejects a manual sync before creating a run when the expected app does not match", async () => {
mocks.connectionFindFirst.mockResolvedValueOnce(null);
await expect(
runSyncForConnection({
tenantId: "tenant-1",
connectionId: "kwp-connection",
expectedAppId: "sample-data-sync",
kind: "manual",
}),
).rejects.toMatchObject({ status: 404 });
expect(mocks.connectionFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "kwp-connection", tenantId: "tenant-1", appId: "sample-data-sync" },
}),
);
expect(mocks.syncRunCreate).not.toHaveBeenCalled();
});
it("binds sync history and run detail to both the connection and its expected app", async () => {
mocks.syncRunFindFirst.mockResolvedValueOnce(null);
await expect(listSyncRuns("tenant-1", "connection-1", "sample-data-sync")).resolves.toEqual([]);
expect(mocks.syncRunFindMany).toHaveBeenCalledWith({
where: {
tenantId: "tenant-1",
dataConnectionId: "connection-1",
dataConnection: { tenantId: "tenant-1", appId: "sample-data-sync" },
},
orderBy: { createdAt: "desc" },
take: 50,
});
await expect(
getSyncRunWithItems("tenant-1", "run-1", "connection-1", "sample-data-sync"),
).rejects.toMatchObject({ status: 404 });
expect(mocks.syncRunFindFirst).toHaveBeenCalledWith({
where: {
id: "run-1",
tenantId: "tenant-1",
dataConnectionId: "connection-1",
dataConnection: { tenantId: "tenant-1", appId: "sample-data-sync" },
},
});
expect(mocks.syncItemFindMany).not.toHaveBeenCalled();
expect(mocks.syncRunLogFindMany).not.toHaveBeenCalled();
});
it("links an unhealthy-connection notification to the affected connection settings", async () => {
mocks.connectionFindFirst.mockResolvedValueOnce({
id: "connection-1",
appId: "aircall",
connectionStatus: "active",
credentialsCiphertext: null,
createdByUserId: "user-1",
});
await expect(
checkConnectionHealth({
tenantId: "tenant-1",
connectionId: "connection-1",
notificationDeeplink: "/acme/settings/integrations/telephony",
}),
).resolves.toEqual({ status: "broken", messageKey: "integrations.health.notConnected" });
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: "tenant-1",
userId: "user-1",
messageKey: "integrations.notify.connectionUnhealthy",
sourceKey: "integrations:connection-unhealthy:connection-1",
payloadMetadata: expect.objectContaining({
connectionId: "connection-1",
health: "broken",
deeplink: "/acme/settings/integrations/telephony",
}),
}),
expect.anything(),
);
});
});