234 lines
7.8 KiB
TypeScript
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(),
|
|
);
|
|
});
|
|
});
|