Skip to content

Commit 4db9f23

Browse files
authored
Merge pull request #3382 from trycompai/main
[comp] Production Deploy
2 parents 0b8826b + f0c9f2c commit 4db9f23

20 files changed

Lines changed: 1262 additions & 162 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { SyncController } from './sync.controller';
3+
import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
4+
import { PermissionGuard } from '../../auth/permission.guard';
5+
import { ConnectionRepository } from '../repositories/connection.repository';
6+
import { CredentialVaultService } from '../services/credential-vault.service';
7+
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
8+
import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service';
9+
import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service';
10+
import { GenericDeviceSyncService } from '../services/generic-device-sync.service';
11+
import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
12+
import { CheckRunRepository } from '../repositories/check-run.repository';
13+
14+
const mockConnectionFindMany = jest.fn();
15+
const mockGetActiveManifests = jest.fn();
16+
17+
jest.mock('@db', () => ({
18+
db: {
19+
integrationConnection: {
20+
findMany: (...args: unknown[]) => mockConnectionFindMany(...args),
21+
},
22+
},
23+
}));
24+
25+
jest.mock('../../auth/auth.server', () => ({
26+
auth: { api: { getSession: jest.fn() } },
27+
}));
28+
29+
jest.mock('@trycompai/auth', () => ({
30+
statement: { integration: ['create', 'read', 'update', 'delete'] },
31+
BUILT_IN_ROLE_PERMISSIONS: {},
32+
}));
33+
34+
jest.mock('@trycompai/integration-platform', () => {
35+
const actual = jest.requireActual<
36+
typeof import('@trycompai/integration-platform')
37+
>('@trycompai/integration-platform');
38+
return {
39+
...actual,
40+
registry: {
41+
getActiveManifests: (...args: unknown[]) =>
42+
mockGetActiveManifests(...args),
43+
},
44+
TASK_TEMPLATE_INFO: {},
45+
};
46+
});
47+
48+
describe('SyncController - getAvailableSyncProviders connection status', () => {
49+
let controller: SyncController;
50+
51+
const orgId = 'org_test123';
52+
const intuneManifest = {
53+
id: 'intune',
54+
name: 'Intune',
55+
logoUrl: 'https://example.com/intune.png',
56+
capabilities: ['checks', 'device_sync'],
57+
};
58+
59+
beforeEach(async () => {
60+
jest.clearAllMocks();
61+
mockGetActiveManifests.mockReturnValue([intuneManifest]);
62+
63+
const module: TestingModule = await Test.createTestingModule({
64+
controllers: [SyncController],
65+
providers: [
66+
{
67+
provide: ConnectionRepository,
68+
useValue: { findById: jest.fn(), update: jest.fn() },
69+
},
70+
{
71+
provide: CredentialVaultService,
72+
useValue: {
73+
getDecryptedCredentials: jest.fn(),
74+
refreshOAuthTokens: jest.fn(),
75+
},
76+
},
77+
{
78+
provide: OAuthCredentialsService,
79+
useValue: { getCredentials: jest.fn() },
80+
},
81+
{
82+
provide: IntegrationSyncLoggerService,
83+
useValue: { logSync: jest.fn() },
84+
},
85+
{ provide: GenericEmployeeSyncService, useValue: {} },
86+
{ provide: GenericDeviceSyncService, useValue: {} },
87+
{ provide: DynamicIntegrationRepository, useValue: {} },
88+
{
89+
provide: CheckRunRepository,
90+
useValue: { create: jest.fn(), complete: jest.fn() },
91+
},
92+
],
93+
})
94+
.overrideGuard(HybridAuthGuard)
95+
.useValue({ canActivate: () => true })
96+
.overrideGuard(PermissionGuard)
97+
.useValue({ canActivate: () => true })
98+
.compile();
99+
100+
controller = module.get<SyncController>(SyncController);
101+
});
102+
103+
it('reports connected + connectionStatus active for an active connection', async () => {
104+
mockConnectionFindMany.mockResolvedValue([
105+
{
106+
id: 'icn_active',
107+
status: 'active',
108+
lastSyncAt: null,
109+
nextSyncAt: null,
110+
provider: { slug: 'intune' },
111+
},
112+
]);
113+
114+
const result = await controller.getAvailableSyncProviders(orgId, 'device');
115+
116+
expect(result.providers).toEqual([
117+
expect.objectContaining({
118+
slug: 'intune',
119+
connected: true,
120+
connectionStatus: 'active',
121+
connectionId: 'icn_active',
122+
}),
123+
]);
124+
// ONE batched query for all providers — no per-provider point lookups.
125+
expect(mockConnectionFindMany).toHaveBeenCalledTimes(1);
126+
const args = mockConnectionFindMany.mock.calls[0][0] as {
127+
where: { provider: { slug: { in: string[] } } };
128+
orderBy: { updatedAt: string };
129+
};
130+
expect(args.where.provider.slug.in).toEqual(['intune']);
131+
expect(args.orderBy).toEqual({ updatedAt: 'desc' });
132+
});
133+
134+
it('reports connectionStatus error (not connected) when the latest connection is broken', async () => {
135+
mockConnectionFindMany.mockResolvedValue([
136+
{ id: 'icn_broken', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
137+
]);
138+
139+
const result = await controller.getAvailableSyncProviders(orgId, 'device');
140+
141+
expect(result.providers).toEqual([
142+
expect.objectContaining({
143+
slug: 'intune',
144+
connected: false,
145+
connectionStatus: 'error',
146+
connectionId: null,
147+
}),
148+
]);
149+
});
150+
151+
it('does not resurface a stale error when the latest connection is disconnected', async () => {
152+
// Rows arrive newest-first (orderBy updatedAt desc): the user reconnected
153+
// and later disconnected, leaving an older row stuck in 'error'.
154+
mockConnectionFindMany.mockResolvedValue([
155+
{ id: 'icn_new', status: 'disconnected', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
156+
{ id: 'icn_old', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
157+
]);
158+
159+
const result = await controller.getAvailableSyncProviders(orgId, 'device');
160+
161+
expect(result.providers).toEqual([
162+
expect.objectContaining({
163+
slug: 'intune',
164+
connected: false,
165+
connectionStatus: null,
166+
connectionId: null,
167+
}),
168+
]);
169+
});
170+
171+
it('prefers an active connection even when a newer non-active row exists', async () => {
172+
mockConnectionFindMany.mockResolvedValue([
173+
{ id: 'icn_new_err', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
174+
{ id: 'icn_active', status: 'active', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } },
175+
]);
176+
177+
const result = await controller.getAvailableSyncProviders(orgId, 'device');
178+
179+
expect(result.providers).toEqual([
180+
expect.objectContaining({
181+
slug: 'intune',
182+
connected: true,
183+
connectionStatus: 'active',
184+
connectionId: 'icn_active',
185+
}),
186+
]);
187+
});
188+
189+
it('reports connectionStatus null when the org has no connection for the provider', async () => {
190+
mockConnectionFindMany.mockResolvedValue([]);
191+
192+
const result = await controller.getAvailableSyncProviders(orgId, 'device');
193+
194+
expect(result.providers).toEqual([
195+
expect.objectContaining({
196+
slug: 'intune',
197+
connected: false,
198+
connectionStatus: null,
199+
connectionId: null,
200+
}),
201+
]);
202+
});
203+
});

‎apps/api/src/integration-platform/controllers/sync.controller.ts‎

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,32 +1777,55 @@ export class SyncController {
17771777
m.capabilities?.includes(capability),
17781778
);
17791779

1780-
const results = await Promise.all(
1781-
syncProviders.map(async (m) => {
1782-
const connection = await db.integrationConnection.findFirst({
1783-
where: {
1784-
organizationId,
1785-
status: 'active',
1786-
provider: { slug: m.id },
1787-
},
1788-
select: {
1789-
id: true,
1790-
status: true,
1791-
lastSyncAt: true,
1792-
nextSyncAt: true,
1793-
},
1794-
});
1795-
return {
1796-
slug: m.id,
1797-
name: m.name,
1798-
logoUrl: m.logoUrl,
1799-
connected: !!connection,
1800-
connectionId: connection?.id ?? null,
1801-
lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null,
1802-
nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null,
1803-
};
1804-
}),
1805-
);
1780+
// One batched query for every provider's connections (newest first), then
1781+
// resolve per-provider status in memory — avoids N (to 2N) point lookups.
1782+
const orgConnections = await db.integrationConnection.findMany({
1783+
where: {
1784+
organizationId,
1785+
provider: { slug: { in: syncProviders.map((m) => m.id) } },
1786+
},
1787+
select: {
1788+
id: true,
1789+
status: true,
1790+
lastSyncAt: true,
1791+
nextSyncAt: true,
1792+
provider: { select: { slug: true } },
1793+
},
1794+
orderBy: { updatedAt: 'desc' },
1795+
});
1796+
const connectionsBySlug = new Map<string, typeof orgConnections>();
1797+
for (const conn of orgConnections) {
1798+
const slug = conn.provider.slug;
1799+
const list = connectionsBySlug.get(slug);
1800+
if (list) list.push(conn);
1801+
else connectionsBySlug.set(slug, [conn]);
1802+
}
1803+
1804+
const results = syncProviders.map((m) => {
1805+
const conns = connectionsBySlug.get(m.id) ?? [];
1806+
const connection = conns.find((c) => c.status === 'active');
1807+
// No active connection: surface a broken (errored) one so the UI can
1808+
// prompt a reconnect instead of silently hiding sync for the provider.
1809+
// Multiple rows can exist per (org, provider) — reconnects create new
1810+
// rows — so the LATEST row decides: a newer 'disconnected' row means
1811+
// the user chose to disconnect, and a stale older 'error' row must
1812+
// not resurface a reconnect hint.
1813+
const latestConnection = connection ? null : (conns[0] ?? null);
1814+
return {
1815+
slug: m.id,
1816+
name: m.name,
1817+
logoUrl: m.logoUrl,
1818+
connected: !!connection,
1819+
connectionStatus: connection
1820+
? ('active' as const)
1821+
: latestConnection?.status === 'error'
1822+
? ('error' as const)
1823+
: null,
1824+
connectionId: connection?.id ?? null,
1825+
lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null,
1826+
nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null,
1827+
};
1828+
});
18061829

18071830
return { providers: results };
18081831
}

0 commit comments

Comments
 (0)