Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SyncController } from './sync.controller';
import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
import { PermissionGuard } from '../../auth/permission.guard';
import { ConnectionRepository } from '../repositories/connection.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service';
import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service';
import { GenericDeviceSyncService } from '../services/generic-device-sync.service';
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';

const mockConnectionFindMany = jest.fn();
const mockGetActiveManifests = jest.fn();

jest.mock('@db', () => ({
db: {
integrationConnection: {
findMany: (...args: unknown[]) => mockConnectionFindMany(...args),
},
},
}));

jest.mock('../../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
}));

jest.mock('@trycompai/auth', () => ({
statement: { integration: ['create', 'read', 'update', 'delete'] },
BUILT_IN_ROLE_PERMISSIONS: {},
}));

jest.mock('@trycompai/integration-platform', () => {
const actual = jest.requireActual<
typeof import('@trycompai/integration-platform')
>('@trycompai/integration-platform');
return {
...actual,
registry: {
getActiveManifests: (...args: unknown[]) =>
mockGetActiveManifests(...args),
},
TASK_TEMPLATE_INFO: {},
};
});

describe('SyncController - getAvailableSyncProviders connection status', () => {
let controller: SyncController;

const orgId = 'org_test123';
const intuneManifest = {
id: 'intune',
name: 'Intune',
logoUrl: 'https://example.com/intune.png',
capabilities: ['checks', 'device_sync'],
};

beforeEach(async () => {
jest.clearAllMocks();
mockGetActiveManifests.mockReturnValue([intuneManifest]);

const module: TestingModule = await Test.createTestingModule({
controllers: [SyncController],
providers: [
{
provide: ConnectionRepository,
useValue: { findById: jest.fn(), update: jest.fn() },
},
{
provide: CredentialVaultService,
useValue: {
getDecryptedCredentials: jest.fn(),
refreshOAuthTokens: jest.fn(),
},
},
{
provide: OAuthCredentialsService,
useValue: { getCredentials: jest.fn() },
},
{
provide: IntegrationSyncLoggerService,
useValue: { logSync: jest.fn() },
},
{ provide: GenericEmployeeSyncService, useValue: {} },
{ provide: GenericDeviceSyncService, useValue: {} },
{ provide: DynamicIntegrationRepository, useValue: {} },
{
provide: CheckRunRepository,
useValue: { create: jest.fn(), complete: jest.fn() },
},
],
})
.overrideGuard(HybridAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(PermissionGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get<SyncController>(SyncController);
});

it('reports connected + connectionStatus active for an active connection', async () => {
mockConnectionFindMany.mockResolvedValue([
{
id: 'icn_active',
status: 'active',
lastSyncAt: null,
nextSyncAt: null,
provider: { slug: 'intune' },
},
]);

const result = await controller.getAvailableSyncProviders(orgId, 'device');

expect(result.providers).toEqual([
expect.objectContaining({
slug: 'intune',
connected: true,
connectionStatus: 'active',
connectionId: 'icn_active',
}),
]);
// ONE batched query for all providers — no per-provider point lookups.
expect(mockConnectionFindMany).toHaveBeenCalledTimes(1);
const args = mockConnectionFindMany.mock.calls[0][0] as {
where: { provider: { slug: { in: string[] } } };
orderBy: { updatedAt: string };
};
expect(args.where.provider.slug.in).toEqual(['intune']);
expect(args.orderBy).toEqual({ updatedAt: 'desc' });
});

it('reports connectionStatus error (not connected) when the latest connection is broken', async () => {
mockConnectionFindMany.mockResolvedValue([
{ id: 'icn_broken', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
]);

const result = await controller.getAvailableSyncProviders(orgId, 'device');

expect(result.providers).toEqual([
expect.objectContaining({
slug: 'intune',
connected: false,
connectionStatus: 'error',
connectionId: null,
}),
]);
});

it('does not resurface a stale error when the latest connection is disconnected', async () => {
// Rows arrive newest-first (orderBy updatedAt desc): the user reconnected
// and later disconnected, leaving an older row stuck in 'error'.
mockConnectionFindMany.mockResolvedValue([
{ id: 'icn_new', status: 'disconnected', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
{ id: 'icn_old', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
]);

const result = await controller.getAvailableSyncProviders(orgId, 'device');

expect(result.providers).toEqual([
expect.objectContaining({
slug: 'intune',
connected: false,
connectionStatus: null,
connectionId: null,
}),
]);
});

it('prefers an active connection even when a newer non-active row exists', async () => {
mockConnectionFindMany.mockResolvedValue([
{ id: 'icn_new_err', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
{ id: 'icn_active', status: 'active', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
]);

const result = await controller.getAvailableSyncProviders(orgId, 'device');

expect(result.providers).toEqual([
expect.objectContaining({
slug: 'intune',
connected: true,
connectionStatus: 'active',
connectionId: 'icn_active',
}),
]);
});

it('reports connectionStatus null when the org has no connection for the provider', async () => {
mockConnectionFindMany.mockResolvedValue([]);

const result = await controller.getAvailableSyncProviders(orgId, 'device');

expect(result.providers).toEqual([
expect.objectContaining({
slug: 'intune',
connected: false,
connectionStatus: null,
connectionId: null,
}),
]);
});
});
75 changes: 49 additions & 26 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1777,32 +1777,55 @@ export class SyncController {
m.capabilities?.includes(capability),
);

const results = await Promise.all(
syncProviders.map(async (m) => {
const connection = await db.integrationConnection.findFirst({
where: {
organizationId,
status: 'active',
provider: { slug: m.id },
},
select: {
id: true,
status: true,
lastSyncAt: true,
nextSyncAt: true,
},
});
return {
slug: m.id,
name: m.name,
logoUrl: m.logoUrl,
connected: !!connection,
connectionId: connection?.id ?? null,
lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null,
nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null,
};
}),
);
// One batched query for every provider's connections (newest first), then
// resolve per-provider status in memory — avoids N (to 2N) point lookups.
const orgConnections = await db.integrationConnection.findMany({
where: {
organizationId,
provider: { slug: { in: syncProviders.map((m) => m.id) } },
},
select: {
id: true,
status: true,
lastSyncAt: true,
nextSyncAt: true,
provider: { select: { slug: true } },
},
orderBy: { updatedAt: 'desc' },
});
const connectionsBySlug = new Map<string, typeof orgConnections>();
for (const conn of orgConnections) {
const slug = conn.provider.slug;
const list = connectionsBySlug.get(slug);
if (list) list.push(conn);
else connectionsBySlug.set(slug, [conn]);
}

const results = syncProviders.map((m) => {
const conns = connectionsBySlug.get(m.id) ?? [];
const connection = conns.find((c) => c.status === 'active');
// No active connection: surface a broken (errored) one so the UI can
// prompt a reconnect instead of silently hiding sync for the provider.
// Multiple rows can exist per (org, provider) — reconnects create new
// rows — so the LATEST row decides: a newer 'disconnected' row means
// the user chose to disconnect, and a stale older 'error' row must
// not resurface a reconnect hint.
const latestConnection = connection ? null : (conns[0] ?? null);
return {
slug: m.id,
name: m.name,
logoUrl: m.logoUrl,
connected: !!connection,
connectionStatus: connection
? ('active' as const)
: latestConnection?.status === 'error'
? ('error' as const)
: null,
connectionId: connection?.id ?? null,
lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null,
nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null,
};
});

return { providers: results };
}
Expand Down
Loading
Loading