Skip to content

Commit 65ce8aa

Browse files
authored
feat: add mock functionality for opportunity-related services (#3380)
1 parent e649fc7 commit 65ce8aa

8 files changed

Lines changed: 376 additions & 18 deletions

File tree

.infra/Pulumi.adhoc.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ config:
8383
pubsubProjectId: local
8484
redisHost: redis
8585
redisPort: 6379
86+
scraperUrl: http://host.docker.internal:5001
8687
serviceName: api
8788
submitArticleThreshold: 250
8889
typeormDatabase: api
@@ -95,6 +96,7 @@ config:
9596
resumeBucketName: adhoc-daily-api
9697
employmentAgreementBucketName: adhoc-daily-api
9798
brokkrOrigin: http://brokkr-grpc:50051
99+
mockExternalServices: true
98100
api:k8s:
99101
namespace: local
100102
api:temporal:

__tests__/schema/opportunity.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4749,6 +4749,61 @@ describe('mutation editOpportunity', () => {
47494749
'Organization with this name already exists',
47504750
);
47514751
});
4752+
4753+
it('should generate unique organization name when name is not provided', async () => {
4754+
loggedUser = '1';
4755+
4756+
const MUTATION_WITH_ORG = /* GraphQL */ `
4757+
mutation EditOpportunityWithOrg(
4758+
$id: ID!
4759+
$payload: OpportunityEditInput!
4760+
) {
4761+
editOpportunity(id: $id, payload: $payload) {
4762+
id
4763+
organization {
4764+
id
4765+
name
4766+
}
4767+
}
4768+
}
4769+
`;
4770+
4771+
const opportunityWithoutOrganization = await con
4772+
.getRepository(OpportunityJob)
4773+
.save({
4774+
...opportunitiesFixture[0],
4775+
id: randomUUID(),
4776+
state: OpportunityState.DRAFT,
4777+
organizationId: null,
4778+
});
4779+
4780+
await con.getRepository(OpportunityUser).save({
4781+
opportunityId: opportunityWithoutOrganization.id,
4782+
userId: loggedUser,
4783+
type: OpportunityUserType.Recruiter,
4784+
});
4785+
4786+
const res = await client.mutate(MUTATION_WITH_ORG, {
4787+
variables: {
4788+
id: opportunityWithoutOrganization.id,
4789+
payload: {
4790+
organization: {}, // No name provided - should auto-generate
4791+
},
4792+
},
4793+
});
4794+
4795+
expect(res.errors).toBeFalsy();
4796+
expect(res.data.editOpportunity.organization).toBeDefined();
4797+
expect(res.data.editOpportunity.organization.name).toMatch(/^Company\d+$/);
4798+
4799+
// Verify the organization was created in database with generated name
4800+
const organization = await con
4801+
.getRepository(Organization)
4802+
.findOneBy({ id: res.data.editOpportunity.organization.id });
4803+
4804+
expect(organization).not.toBeNull();
4805+
expect(organization!.name).toMatch(/^Company\d+$/);
4806+
});
47524807
});
47534808

47544809
describe('mutation clearOrganizationImage', () => {

src/common/brokkr.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import { env } from 'node:process';
22
import { createClient } from '@connectrpc/connect';
33
import { createGrpcTransport } from '@connectrpc/connect-node';
44
import { BrokkrService } from '@dailydotdev/schema';
5-
import { GarmrService } from '../integrations/garmr';
5+
import { GarmrService, GarmrNoopService } from '../integrations/garmr';
66
import type { ServiceClient } from '../types';
7+
import {
8+
isMockEnabled,
9+
mockBrokkrParseOpportunityResponse,
10+
} from '../mocks/opportunity/services';
711

812
const garmBrokkrService = new GarmrService({
913
service: 'brokkr',
@@ -24,10 +28,25 @@ const transport = createGrpcTransport({
2428

2529
export const getBrokkrClient = (
2630
clientTransport = transport,
27-
): ServiceClient<typeof BrokkrService> => ({
28-
instance: createClient<typeof BrokkrService>(BrokkrService, clientTransport),
29-
garmr: garmBrokkrService,
30-
});
31+
): ServiceClient<typeof BrokkrService> => {
32+
if (isMockEnabled()) {
33+
return {
34+
instance: {
35+
parseOpportunity: async () => mockBrokkrParseOpportunityResponse(),
36+
extractMarkdown: async () => ({ markdown: 'Mock CV content' }),
37+
} as unknown as ReturnType<typeof createClient<typeof BrokkrService>>,
38+
garmr: new GarmrNoopService(),
39+
};
40+
}
41+
42+
return {
43+
instance: createClient<typeof BrokkrService>(
44+
BrokkrService,
45+
clientTransport,
46+
),
47+
garmr: garmBrokkrService,
48+
};
49+
};
3150

3251
export const extractMarkdownFromCV = async (
3352
blobName: string,

src/common/gondul.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { createGrpcTransport } from '@connectrpc/connect-node';
2-
import { GarmrService } from '../integrations/garmr';
2+
import { GarmrService, GarmrNoopService } from '../integrations/garmr';
33
import { createClient } from '@connectrpc/connect';
44
import {
55
ApplicationService as GondulService,
66
OpportunityService as GondulOpportunityService,
77
} from '@dailydotdev/schema';
88
import type { ServiceClient } from '../types';
9+
import {
10+
isMockEnabled,
11+
mockGondulScreeningQuestionsResponse,
12+
mockPreviewUserIds,
13+
mockPreviewTotalCount,
14+
} from '../mocks/opportunity/services';
915

1016
const transport = createGrpcTransport({
1117
baseUrl: process.env.GONDUL_ORIGIN,
@@ -27,6 +33,15 @@ const garmGondulService = new GarmrService({
2733
export const getGondulClient = (
2834
clientTransport = transport,
2935
): ServiceClient<typeof GondulService> => {
36+
if (isMockEnabled()) {
37+
return {
38+
instance: {
39+
screeningQuestions: async () => mockGondulScreeningQuestionsResponse(),
40+
} as unknown as ReturnType<typeof createClient<typeof GondulService>>,
41+
garmr: new GarmrNoopService(),
42+
};
43+
}
44+
3045
return {
3146
instance: createClient<typeof GondulService>(
3247
GondulService,
@@ -44,6 +59,22 @@ const gondulOpportunityServerTransport = createGrpcTransport({
4459
export const getGondulOpportunityServiceClient = (
4560
clientTransport = gondulOpportunityServerTransport,
4661
): ServiceClient<typeof GondulOpportunityService> => {
62+
if (isMockEnabled()) {
63+
return {
64+
instance: {
65+
// Preview is async - it triggers a background job
66+
// For mock, we simulate immediate completion by returning userIds
67+
preview: async () => ({
68+
userIds: mockPreviewUserIds,
69+
totalCount: mockPreviewTotalCount,
70+
}),
71+
} as unknown as ReturnType<
72+
typeof createClient<typeof GondulOpportunityService>
73+
>,
74+
garmr: new GarmrNoopService(),
75+
};
76+
}
77+
4778
return {
4879
instance: createClient<typeof GondulOpportunityService>(
4980
GondulOpportunityService,

src/common/scraper.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { GarmrService } from '../integrations/garmr';
1+
import { IDefaultPolicyContext, IPolicy, noop } from 'cockatiel';
2+
import { GarmrService, IGarmrService } from '../integrations/garmr';
3+
import {
4+
isMockEnabled,
5+
mockScraperPdfBuffer,
6+
} from '../mocks/opportunity/services';
27

3-
export const garmScraperService = new GarmrService({
8+
const realGarmScraperService = new GarmrService({
49
service: 'daily-scraper',
510
breakerOpts: {
611
halfOpenAfter: 10 * 1000,
@@ -12,3 +17,29 @@ export const garmScraperService = new GarmrService({
1217
maxAttempts: 3,
1318
},
1419
});
20+
21+
/**
22+
* Mock scraper service that returns a mock PDF buffer
23+
*/
24+
class MockScraperService implements IGarmrService {
25+
readonly instance: IPolicy = noop;
26+
27+
async execute<T>(
28+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
29+
_fn: (context: IDefaultPolicyContext) => PromiseLike<T> | T,
30+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
31+
_signal?: AbortSignal,
32+
): Promise<T> {
33+
// Return a mock Response object with the PDF buffer
34+
const buffer = mockScraperPdfBuffer();
35+
const response = new Response(new Uint8Array(buffer), {
36+
status: 200,
37+
headers: { 'content-type': 'application/pdf' },
38+
});
39+
return response as unknown as T;
40+
}
41+
}
42+
43+
export const garmScraperService: IGarmrService = isMockEnabled()
44+
? new MockScraperService()
45+
: realGarmScraperService;

src/integrations/snotra/clients.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { GarmrNoopService, IGarmrService, GarmrService } from '../garmr';
33
import { fetchOptions as globalFetchOptions } from '../../http';
44
import { retryFetchParse } from '../retry';
55
import { ISnotraClient, ProfileRequest, ProfileResponse } from './types';
6+
import {
7+
isMockEnabled,
8+
mockSnotraEngagementProfile,
9+
} from '../../mocks/opportunity/services';
610

711
export class SnotraClient implements ISnotraClient {
812
private readonly fetchOptions: RequestInit;
@@ -25,6 +29,11 @@ export class SnotraClient implements ISnotraClient {
2529
}
2630

2731
getProfile(request: ProfileRequest): Promise<ProfileResponse> {
32+
// Mock path: return mock engagement profile
33+
if (isMockEnabled()) {
34+
return Promise.resolve(mockSnotraEngagementProfile);
35+
}
36+
2837
return this.garmr.execute(() => {
2938
return retryFetchParse<ProfileResponse>(
3039
`${this.url}/api/v1/memstore/shortprofile`,

src/mocks/opportunity/services.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Mock service responses for opportunity-related external services.
3+
* Only the actual service calls are mocked - all other flow logic remains unchanged.
4+
*/
5+
6+
import {
7+
EmploymentType,
8+
OpportunityType,
9+
SalaryPeriod,
10+
SeniorityLevel,
11+
} from '@dailydotdev/schema';
12+
13+
/**
14+
* Check if external services should be mocked
15+
*/
16+
export const isMockEnabled = (): boolean =>
17+
process.env.MOCK_EXTERNAL_SERVICES === 'true';
18+
19+
/**
20+
* Mock response for Brokkr parseOpportunity service call
21+
* Returns plain objects that match the expected protobuf message shape
22+
*/
23+
export const mockBrokkrParseOpportunityResponse = () => ({
24+
opportunity: {
25+
type: OpportunityType.JOB,
26+
title: 'Senior Full Stack Developer',
27+
tldr: 'Join our team to build cutting-edge developer tools and shape the future of how developers discover content.',
28+
content: {
29+
overview: {
30+
content:
31+
'We are looking for a Senior Full Stack Developer to join our growing engineering team. You will work on building and scaling our platform that serves millions of developers worldwide.',
32+
},
33+
responsibilities: {
34+
content:
35+
'- Design and implement new features across the full stack\n- Collaborate with product and design teams\n- Mentor junior developers\n- Participate in code reviews and architectural decisions',
36+
},
37+
requirements: {
38+
content:
39+
'- 5+ years of experience with modern web technologies\n- Strong proficiency in TypeScript and React\n- Experience with Node.js and PostgreSQL\n- Excellent communication skills',
40+
},
41+
},
42+
meta: {
43+
roleType: 0.0,
44+
teamSize: 15,
45+
seniorityLevel: SeniorityLevel.SENIOR,
46+
employmentType: EmploymentType.FULL_TIME,
47+
salary: {
48+
min: BigInt(120000),
49+
max: BigInt(130000),
50+
currency: 'USD',
51+
period: SalaryPeriod.ANNUAL,
52+
},
53+
equity: true,
54+
},
55+
keywords: ['typescript', 'react', 'nodejs', 'postgresql', 'graphql'],
56+
},
57+
});
58+
59+
/**
60+
* Mock response for Gondul screeningQuestions service call
61+
*/
62+
export const mockGondulScreeningQuestionsResponse = () => ({
63+
screening: [
64+
'What experience do you have building and scaling applications for millions of users?',
65+
'How do you approach mentoring junior developers while maintaining your own productivity?',
66+
'Describe your experience with TypeScript and React in production.',
67+
],
68+
});
69+
70+
/**
71+
* Mock user IDs for opportunityPreview (simulates Gondul preview response)
72+
* These should be valid user IDs from your local database (from seed data)
73+
* Using testuser1-10 which have SourceMember entries for publicsquad
74+
*/
75+
export const mockPreviewUserIds = [
76+
'testuser',
77+
'testuser1',
78+
'testuser2',
79+
'testuser3',
80+
'testuser4',
81+
'testuser5',
82+
'testuser6',
83+
'testuser7',
84+
'testuser8',
85+
'testuser9',
86+
];
87+
export const mockPreviewTotalCount = 4827;
88+
89+
/**
90+
* Mock tags for opportunityPreview result
91+
* These are used when computing the aggregated tags from user data
92+
*/
93+
export const mockPreviewTags = [
94+
'react',
95+
'typescript',
96+
'javascript',
97+
'nodejs',
98+
'python',
99+
'graphql',
100+
'nextjs',
101+
'aws',
102+
];
103+
104+
/**
105+
* Mock squads for opportunityPreview result
106+
* These should match squad IDs from the seed data
107+
*/
108+
export const mockPreviewSquadIds = ['publicsquad'];
109+
110+
/**
111+
* Mock PDF buffer for scraper service (minimal valid PDF)
112+
*/
113+
export const mockScraperPdfBuffer = (): Buffer => {
114+
// Minimal PDF structure
115+
return Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF');
116+
};
117+
118+
/**
119+
* Mock engagement profile returned from Snotra getProfile
120+
*/
121+
export const mockSnotraEngagementProfile = {
122+
profile_text:
123+
'Active developer with strong engagement in React and TypeScript communities. Regular contributor to open source projects and frequent reader of frontend development content. Shows consistent interest in modern web technologies and best practices.',
124+
update_at: new Date().toISOString(),
125+
};

0 commit comments

Comments
 (0)