Skip to content

Commit 0a4e058

Browse files
committed
[eas-cli] Add integrations:posthog:connect (core provision flow)
1 parent bf35e3a commit 0a4e058

2 files changed

Lines changed: 472 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
2+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
3+
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
4+
import {
5+
EnvironmentSecretType,
6+
EnvironmentVariableScope,
7+
EnvironmentVariableVisibility,
8+
PostHogRegion,
9+
} from '../../../../graphql/generated';
10+
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
11+
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
12+
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
13+
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
14+
import {
15+
PostHogOrganizationConnectionData,
16+
PostHogProjectData,
17+
} from '../../../../graphql/types/PostHogConnection';
18+
import Log from '../../../../log';
19+
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
20+
import { confirmAsync, selectAsync } from '../../../../prompts';
21+
import { Actor } from '../../../../user/User';
22+
import IntegrationsPostHogConnect from '../connect';
23+
24+
jest.mock('../../../../graphql/queries/PostHogQuery');
25+
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
26+
jest.mock('../../../../graphql/mutations/PostHogMutation');
27+
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
28+
jest.mock('../../../../project/projectUtils');
29+
jest.mock('../../../../prompts');
30+
jest.mock('../../../../log');
31+
jest.mock('../../../../ora', () => ({
32+
ora: () => ({
33+
start: jest.fn().mockReturnThis(),
34+
succeed: jest.fn().mockReturnThis(),
35+
fail: jest.fn().mockReturnThis(),
36+
}),
37+
}));
38+
39+
describe(IntegrationsPostHogConnect, () => {
40+
const graphqlClient = {} as ExpoGraphqlClient;
41+
const mockConfig = getMockOclifConfig();
42+
const testAccountId = 'test-account-id';
43+
const testAccountName = 'testuser';
44+
45+
const mockActor: Actor = {
46+
__typename: 'User',
47+
id: 'test-user-id',
48+
username: testAccountName,
49+
email: 'user@example.com',
50+
featureGates: {},
51+
isExpoAdmin: false,
52+
primaryAccount: {
53+
id: testAccountId,
54+
name: testAccountName,
55+
ownerUserActor: null,
56+
users: [],
57+
},
58+
preferences: { onboarding: null },
59+
accounts: [],
60+
};
61+
62+
const mockAccount = {
63+
id: testAccountId,
64+
name: testAccountName,
65+
ownerUserActor: { id: 'test-user-id', username: testAccountName },
66+
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
67+
};
68+
69+
const mockConnection: PostHogOrganizationConnectionData = {
70+
id: 'connection-1',
71+
posthogOrganizationIdentifier: 'org-123',
72+
posthogOrganizationName: 'Test Org',
73+
posthogRegion: PostHogRegion.Us,
74+
createdAt: '2024-01-01T00:00:00.000Z',
75+
updatedAt: '2024-01-01T00:00:00.000Z',
76+
};
77+
78+
const mockProject: PostHogProjectData = {
79+
id: 'project-1',
80+
posthogProjectIdentifier: 'res-123',
81+
posthogProjectName: 'testapp',
82+
posthogProjectToken: 'phc_public_key',
83+
posthogHost: 'https://us.posthog.com',
84+
createdAt: '2024-01-01T00:00:00.000Z',
85+
updatedAt: '2024-01-01T00:00:00.000Z',
86+
posthogOrganizationConnection: mockConnection,
87+
};
88+
89+
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsPostHogConnect {
90+
const command = new IntegrationsPostHogConnect(argv, mockConfig);
91+
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
92+
privateProjectConfig: {
93+
projectId: testProjectId,
94+
exp: { name: 'testapp', slug: 'testapp' },
95+
projectDir: '/test/project',
96+
},
97+
loggedIn: { graphqlClient, actor },
98+
} as never);
99+
return command;
100+
}
101+
102+
beforeEach(() => {
103+
jest.resetAllMocks();
104+
jest.spyOn(Log, 'log').mockImplementation(() => {});
105+
jest.spyOn(Log, 'warn').mockImplementation(() => {});
106+
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
107+
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
108+
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
109+
110+
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
111+
jest.mocked(selectAsync).mockResolvedValue(PostHogRegion.Us);
112+
jest.mocked(confirmAsync).mockResolvedValue(true);
113+
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockResolvedValue(mockConnection);
114+
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mockResolvedValue(mockProject);
115+
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
116+
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
117+
id: 'env-var-1',
118+
scope: EnvironmentVariableScope.Project,
119+
visibility: EnvironmentVariableVisibility.Public,
120+
type: EnvironmentSecretType.String,
121+
} as any);
122+
});
123+
124+
it('provisions a new PostHog organization + project and writes the EXPO_PUBLIC_* env vars', async () => {
125+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue(
126+
[]
127+
);
128+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
129+
130+
await createCommand(['--region', 'US']).runAsync();
131+
132+
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
133+
accountId: testAccountId,
134+
region: PostHogRegion.Us,
135+
});
136+
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalledWith(graphqlClient, {
137+
appId: testProjectId,
138+
posthogOrganizationConnectionId: mockConnection.id,
139+
});
140+
const createdNames = jest
141+
.mocked(EnvironmentVariableMutation.createForAppAsync)
142+
.mock.calls.map(call => call[1].name);
143+
expect(createdNames).toEqual(
144+
expect.arrayContaining(['EXPO_PUBLIC_POSTHOG_API_KEY', 'EXPO_PUBLIC_POSTHOG_HOST'])
145+
);
146+
expect(jest.mocked(EnvironmentVariableMutation.createForAppAsync).mock.calls[0][1].value).toBe(
147+
'phc_public_key'
148+
);
149+
});
150+
151+
it('reuses an existing connection and project for the region without provisioning', async () => {
152+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue([
153+
mockConnection,
154+
]);
155+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
156+
157+
await createCommand(['--region', 'US']).runAsync();
158+
159+
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
160+
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
161+
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
162+
});
163+
164+
it('surfaces the existing-account dead-end without provisioning a project', async () => {
165+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue(
166+
[]
167+
);
168+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
169+
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockRejectedValue(
170+
Object.assign(new Error("You already have a PostHog account. Existing-account sign-in isn't supported yet."), {
171+
graphQLErrors: [
172+
{ extensions: { errorCode: 'POSTHOG_EXISTING_USER_NOT_SUPPORTED_ERROR' } },
173+
],
174+
})
175+
);
176+
177+
await createCommand(['--region', 'US']).runAsync();
178+
179+
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
180+
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
181+
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('already have a PostHog account'));
182+
});
183+
184+
it('rethrows non-dead-end provisioning errors', async () => {
185+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue(
186+
[]
187+
);
188+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
189+
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockRejectedValue(
190+
new Error('boom')
191+
);
192+
193+
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow('boom');
194+
});
195+
196+
it('updates existing env vars when present and overwrite is confirmed', async () => {
197+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue([
198+
mockConnection,
199+
]);
200+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
201+
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
202+
{ id: 'env-existing', scope: EnvironmentVariableScope.Project } as any,
203+
]);
204+
jest.mocked(confirmAsync).mockResolvedValue(true);
205+
206+
await createCommand(['--region', 'US']).runAsync();
207+
208+
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledTimes(2);
209+
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
210+
});
211+
212+
it('skips updating an existing env var when overwrite is declined', async () => {
213+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue([
214+
mockConnection,
215+
]);
216+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
217+
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([
218+
{ id: 'env-existing', scope: EnvironmentVariableScope.Project } as any,
219+
]);
220+
jest.mocked(confirmAsync).mockResolvedValue(false);
221+
222+
await createCommand(['--region', 'US']).runAsync();
223+
224+
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
225+
});
226+
227+
it('requires an explicit region in non-interactive mode (no silent US default)', async () => {
228+
jest.mocked(PostHogQuery.getPostHogOrganizationConnectionsByAccountIdAsync).mockResolvedValue(
229+
[]
230+
);
231+
232+
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
233+
/region is required in non-interactive mode/
234+
);
235+
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
236+
});
237+
});

0 commit comments

Comments
 (0)