Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
8aac966
Add db schema
joeauyeung Dec 9, 2025
6e50f46
Add `CredentialRepository.findByTeamIdAndSlugs`
joeauyeung Dec 9, 2025
dd776bf
Add enabled app slugs for attribute syncing
joeauyeung Dec 9, 2025
49e3f33
Create repository for `IntegrationAttributeSync`
joeauyeung Dec 9, 2025
2c897bf
Create zod schemas
joeauyeung Dec 9, 2025
4968b21
Create `AttributeSyncUserRuleOutputMapper`
joeauyeung Dec 9, 2025
deeec8e
Create `IntegrationAttributeSyncService`
joeauyeung Dec 9, 2025
25ca942
Create DI contianer
joeauyeung Dec 9, 2025
7b178e9
Create trpc endpoints
joeauyeung Dec 9, 2025
09ac928
Create page
joeauyeung Dec 9, 2025
c0a71d2
Include team name in `CredentialRepository.findByTeamIdAndSlugs`
joeauyeung Dec 11, 2025
22dbcfb
Update schema and relations
joeauyeung Dec 11, 2025
0264a9c
Update types and schemas
joeauyeung Dec 11, 2025
80144ab
Add more methods to IntegrationAttributeSyncRepository
joeauyeung Dec 11, 2025
8d0d61f
Add more services to `IntegrationAttributeSyncService`
joeauyeung Dec 11, 2025
fd39eae
Refactor `getTeams.handler` to use repository
joeauyeung Dec 11, 2025
e83a8e4
Create `createAttributeSync` trpc endpoint
joeauyeung Dec 11, 2025
b0eba94
Create `updateAttributeSync` trpc endpoint
joeauyeung Dec 11, 2025
a4796d3
Add router to trpc
joeauyeung Dec 11, 2025
ccbf84e
Create attribute sync child components
joeauyeung Dec 11, 2025
3ce313d
Pass custom actions to `FormCard`
joeauyeung Dec 11, 2025
98af081
Create `IntegrationAttributeSyncCard`
joeauyeung Dec 11, 2025
3303c2f
Pass inital props via server side
joeauyeung Dec 11, 2025
9cc2e5a
Merge branch 'main' into attribute-sync-ui
joeauyeung Dec 15, 2025
3fd9430
Fix prop
joeauyeung Dec 17, 2025
a6a4dc8
Only refetch on mutation
joeauyeung Dec 17, 2025
96802ed
Fixes
joeauyeung Dec 17, 2025
bbce738
Add form error when duplicate field and attribute combo
joeauyeung Dec 17, 2025
013c51e
Add `updateTransactionWithRUleAndMappings` logic
joeauyeung Dec 17, 2025
327ea66
Adjust zod schemas
joeauyeung Dec 17, 2025
19c8423
Service add `updateIncludeRulesAndMappings`
joeauyeung Dec 17, 2025
652c87c
Pass orgId from server to component
joeauyeung Dec 17, 2025
daad1d5
Rename types
joeauyeung Dec 17, 2025
5d176bd
Add deleteById method to repository
joeauyeung Dec 17, 2025
da07950
Add name to integrationAttributeSync
joeauyeung Dec 17, 2025
48fe98c
Add deleteById method to service
joeauyeung Dec 17, 2025
fece321
Rename method
joeauyeung Dec 17, 2025
920bd80
Add deleteAttributeSync trpc endpoint
joeauyeung Dec 17, 2025
6857276
Make the IntegrationAttributeSyncCard a dummy component
joeauyeung Dec 17, 2025
8fc4eaf
test: add tests for IntegrationAttributeSync feature
devin-ai-integration[bot] Dec 18, 2025
6d98c8b
Move creating a attribute sync record to the service
joeauyeung Dec 18, 2025
26889b6
Add i18n strings
joeauyeung Dec 18, 2025
3b9f334
Safe select credential in find by id and team
joeauyeung Dec 18, 2025
eea3710
Fix default credentialId value in form
joeauyeung Dec 18, 2025
c43f6ad
Update repository return types
joeauyeung Dec 18, 2025
9113610
Add i18n string
joeauyeung Dec 18, 2025
53c50ef
Make credentialId optional for form schema
joeauyeung Dec 18, 2025
24255e6
Fix label
joeauyeung Dec 18, 2025
ef3b4a5
Add cascade deletes
joeauyeung Dec 18, 2025
767e5dc
Add verification that syncs belong to org
joeauyeung Dec 18, 2025
60f4171
Create mapper for repository output
joeauyeung Dec 18, 2025
cf0cbe0
Type fixes
joeauyeung Dec 18, 2025
1c0b033
Remove old test file
joeauyeung Dec 18, 2025
04f60d7
Pass `attributeOptions` from parent to children
joeauyeung Dec 19, 2025
08cd9a0
Infer types from zod schema
joeauyeung Dec 19, 2025
fec867f
Type fixes
joeauyeung Dec 19, 2025
947d91e
Type fix
joeauyeung Dec 19, 2025
28ed00d
Clean up
joeauyeung Dec 19, 2025
9b0d437
Add i18n strings
joeauyeung Dec 19, 2025
898542f
Remove unused file
joeauyeung Dec 19, 2025
ad65b71
Address feedback
joeauyeung Dec 19, 2025
b288081
Merge branch 'main' into attribute-sync-ui
joeauyeung Dec 19, 2025
1a3abbc
Add migration file
joeauyeung Dec 19, 2025
fce660a
Address feedback
joeauyeung Dec 19, 2025
e37b5d8
Add validation for new integration values
joeauyeung Dec 19, 2025
a83c2e9
Remove unused router
joeauyeung Dec 19, 2025
544877d
Move away from z.infer to z.ZodType
joeauyeung Dec 20, 2025
5c43536
Clean up comments
joeauyeung Dec 20, 2025
8c38861
Type fix
joeauyeung Dec 20, 2025
2eceb83
Type fixes
joeauyeung Dec 20, 2025
dda286d
Type fix
joeauyeung Dec 23, 2025
ba5d4a4
Merge branch 'main' into attribute-sync-ui
joeauyeung Dec 23, 2025
ad1e092
fix: add passthrough to syncFormDataSchema to preserve extra fields
devin-ai-integration[bot] Dec 23, 2025
4f1ce3f
fix: remove incorrect test that expected extra fields to pass through…
devin-ai-integration[bot] Dec 23, 2025
c2252a6
Add endpoint for SF to call
joeauyeung Dec 31, 2025
e475921
Create scratch org config
joeauyeung Dec 31, 2025
2fc58dc
Create sf cli scripts
joeauyeung Dec 31, 2025
efa9260
Create package logic
joeauyeung Dec 31, 2025
e28dc1d
Update README
joeauyeung Dec 31, 2025
7e549d3
Remove unused file
joeauyeung Dec 31, 2025
ebf508c
Add indexes
joeauyeung Dec 31, 2025
3c0a79d
Add aria label
joeauyeung Dec 31, 2025
6ff5680
Address feedback - consistent validation
joeauyeung Dec 31, 2025
d60966d
Merge branch 'main' into attribute-sync-ui
joeauyeung Dec 31, 2025
2d336b2
Merge branch 'main' into attribute-sync-ui
joeauyeung Dec 31, 2025
4578077
Fix import paths for attribute types
joeauyeung Dec 31, 2025
20fd629
Add `CredentialRepository.findByAppIdAndKeyValue`
joeauyeung Jan 3, 2026
b96c26c
Get credential by instance URL
joeauyeung Jan 3, 2026
6d39072
Verify incoming sfdc orgId matches credential sfdc orgId
joeauyeung Jan 3, 2026
751add9
Rename method
joeauyeung Jan 5, 2026
3cde78d
Get user name integration syncs
joeauyeung Jan 5, 2026
a8e4ede
Merge branch 'attribute-sync-ui' into init-salesforce-package
joeauyeung Jan 6, 2026
fe847ae
Merge branch 'init-salesforce-package' into attribute-sync-logic
joeauyeung Jan 6, 2026
dad327a
Merge branch 'main' into attribute-sync-ui
joeauyeung Jan 6, 2026
a41af8e
Merge branch 'attribute-sync-ui' into init-salesforce-package
joeauyeung Jan 6, 2026
c34b00e
Merge branch 'init-salesforce-package' into attribute-sync-logic
joeauyeung Jan 6, 2026
baca20b
refactor: change attributeSyncRules array to singular attributeSyncRule
devin-ai-integration[bot] Jan 6, 2026
4a516ad
Merge branch 'attribute-sync-ui' into init-salesforce-package
joeauyeung Jan 6, 2026
b259a4f
Merge branch 'init-salesforce-package' into attribute-sync-logic
joeauyeung Jan 6, 2026
1b88faf
Merge branch 'main' into attribute-sync-ui
joeauyeung Jan 9, 2026
1034c4f
Merge branch 'attribute-sync-ui' into init-salesforce-package
joeauyeung Jan 9, 2026
22da98c
Merge branch 'init-salesforce-package' into attribute-sync-logic
joeauyeung Jan 9, 2026
51fcf5d
Convert `membershipRepository.findAllByUserId` to a normal method
joeauyeung Jan 6, 2026
ac481a6
Add temp files to git ignore
joeauyeung Jan 6, 2026
084faed
Init and process team conditions
joeauyeung Jan 6, 2026
8c8d282
Biome formatting
joeauyeung Jan 7, 2026
d6480e4
Add `AttributeService` to get user attributes
joeauyeung Jan 7, 2026
12d3997
Add DI container for AttributeService
joeauyeung Jan 7, 2026
7806b2c
AttributeSyncService evaluate attribute conditions
joeauyeung Jan 7, 2026
d126a57
Create DI container for attributeSyncService
joeauyeung Jan 7, 2026
95ce977
Return result for full condition
joeauyeung Jan 8, 2026
be1c679
Evaluate if attribute sync should apply to user
joeauyeung Jan 8, 2026
961bae6
Add method
joeauyeung Jan 9, 2026
bdf1029
Change PrismaAttributeOptionRepository to instance methods
joeauyeung Jan 9, 2026
0454827
Init AttributeSyncFieldMappingsService and process attribute syncs
joeauyeung Jan 9, 2026
f474450
Add AttributeSyncFieldMappingService DI container
joeauyeung Jan 9, 2026
3b5d82e
Refactor orgId to organizationId
joeauyeung Jan 12, 2026
09b12dc
Add membership validation to sync field service
joeauyeung Jan 12, 2026
76341a1
AttributeSYncFieldMappingService use repository methods
joeauyeung Jan 12, 2026
c6d77aa
AttributeSyncFieldMappingService.processMappings add mapping logic
joeauyeung Jan 12, 2026
39f2d98
Add DI tokens
joeauyeung Jan 12, 2026
d0719ab
user-sync endpoint to implement attribute syncing
joeauyeung Jan 12, 2026
7e258c9
Validate team belongs to org for rule
joeauyeung Jan 12, 2026
d0bfb29
test: add tests for AttributeSyncRuleService, AttributeSyncFieldMappi…
devin-ai-integration[bot] Jan 12, 2026
0c13825
Merge branch 'main' into attribute-sync-logic
joeauyeung Jan 14, 2026
5fcdde5
Remove duplicate migration file
joeauyeung Jan 14, 2026
45966c8
Fix merge conflict
joeauyeung Jan 14, 2026
d0b145f
fix: resolve type errors in attribute sync feature (#26814)
hariombalhara Jan 14, 2026
22a12e8
Rename variable
joeauyeung Jan 14, 2026
efa51ce
Fix log typo
joeauyeung Jan 14, 2026
4d3cde9
Fix file name
joeauyeung Jan 14, 2026
fa7724a
Add error logging
joeauyeung Jan 14, 2026
9d35ac7
Use credential teamId
joeauyeung Jan 14, 2026
964d207
fix: add missing mockTeamRepository and team validation to tests
devin-ai-integration[bot] Jan 14, 2026
9de1027
Fix naming
joeauyeung Jan 14, 2026
4179ce4
Fix file import
joeauyeung Jan 14, 2026
4a9e060
Type fix
joeauyeung Jan 14, 2026
d1368fa
Pass MembershipRepository as a dep in AttributeSyncFieldMappingService
joeauyeung Jan 14, 2026
b218bf0
Type fix
joeauyeung Jan 14, 2026
3efe7a5
fix: add mockMembershipRepository to AttributeSyncFieldMappingService…
devin-ai-integration[bot] Jan 14, 2026
d4f3198
Merge branch 'main' into attribute-sync-logic
joeauyeung Jan 14, 2026
168148c
Update packages/app-store/salesforce/api/user-sync.ts
joeauyeung Jan 14, 2026
2d46869
fix: address Cubic code review comments
devin-ai-integration[bot] Jan 14, 2026
f9fe52c
Fix typo in CredentialRepository
joeauyeung Jan 14, 2026
e5b5c6d
Update README
joeauyeung Jan 15, 2026
f244af4
Update sfdx-project
joeauyeung Jan 15, 2026
8a40874
Add SFDC package tests
joeauyeung Jan 15, 2026
2febd5d
fix: improve Salesforce Apex test assertions to verify actual behavior
devin-ai-integration[bot] Jan 15, 2026
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
3 changes: 2 additions & 1 deletion packages/app-store/salesforce/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ src/gql/fragment-masking.ts
# src/gql/gql.ts
src/gql/graphql.ts
src/gql/index.ts
schema.graphql
schema.graphql
sfdc-package/.sf
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary files generated when creating a scratch org via the SF CLI tool

74 changes: 74 additions & 0 deletions packages/app-store/salesforce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,77 @@ When working with graphql files ensure that `yarn codegen:watch` is running in t
The SFDC package is written using Apex. To develop this package, you need to have the Salesforce CLI installed. Then you can run `yarn sfdc:deploy:preview` to see what changes will be deployed to the scratch org. Running `yarn sfdc:deploy` will deploy the changes to the scratch org.

Note that if you want to call your local development instances you need to change the "Named Credential" on the scratch org settings to point the `CalCom_Development` credential to the local instance.

# Publishing the SFDC Package

All commands should be run from the `sfdc-package` directory:

```bash
cd packages/app-store/salesforce/sfdc-package
```

### Initial Setup (One-time)

If the package doesn't exist yet in the Dev Hub, create it:

```bash
sf package create \
--name "calcom-sfdc-package" \
--package-type Unlocked \
--path force-app \
--target-dev-hub team@cal.com
```

This registers the package and updates `sfdx-project.json` with the package ID.

### Creating a New Package Version

Each time you want to release changes, create a new version:

```bash
sf package version create \
--package "calcom-sfdc-package" \
--installation-key-bypass \
--wait 20 \
--target-dev-hub team@cal.com
```

Options:
- `--installation-key-bypass`: Allows installation without a password
- `--wait 20`: Waits up to 20 minutes for completion
- `--code-coverage`: Add this flag when ready to promote (requires 75% Apex test coverage)

### Viewing Packages and Installation URLs

List all package versions:

```bash
sf package version list --target-dev-hub team@cal.com
```

The installation URL format is:

```
https://login.salesforce.com/packaging/installPackage.apexp?p0=<04t_SUBSCRIBER_PACKAGE_VERSION_ID>
```

### Promoting for Production

Beta versions can only be installed in sandboxes/scratch orgs. To allow installation in production orgs, promote the version:

```bash
sf package version promote \
--package "calcom-sfdc-package@X.X.X-X" \
--target-dev-hub team@cal.com
```

Replace `X.X.X-X` with the version number (e.g., `0.1.0-1`).

### Running Tests

To run Apex tests and check code coverage:

```bash
sf project deploy start --target-org <org-alias>
sf apex run test --test-level RunLocalTests --wait 10 --target-org <org-alias>
```
132 changes: 125 additions & 7 deletions packages/app-store/salesforce/api/user-sync.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the endpoint that Salesforce will send the user updated payload to

Original file line number Diff line number Diff line change
@@ -1,25 +1,143 @@
import type { NextApiRequest, NextApiResponse } from "next";

import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { getAttributeSyncRuleService } from "@calcom/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.container";
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import { getAttributeSyncFieldMappingService } from "@calcom/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.container";

const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] });

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}

const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body;
const {
instanceUrl,
orgId: sfdcOrgId,
salesforceUserId,
email,
changedFields,
timestamp,
} = req.body;

log.info("Received user sync request", {
instanceUrl,
orgId,
sfdcOrgId,
salesforceUserId,
email,
changedFields,
timestamp,
});

// TODO: Validate instanceUrl + orgId against stored credentials
// TODO: Sync changedFields to Cal.com user
const credentialRepository = new CredentialRepository(prisma);
const credential = await credentialRepository.findByAppIdAndKeyValue({
appId: "salesforce",
keyPath: ["instance_url"],
value: instanceUrl,
keyFields: ["id"],
});
Comment on lines +39 to +44
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First step of validating that a payload belongs to an org in Cal is to grab the instance URL from the payload and match it with a Salesforce credential


if (!credential) {
log.error(`No credential found for ${instanceUrl}`);
return res.status(400).json({ error: "Invalid instance URL" });
}

if (!credential?.teamId) {
log.error(`Missing teamId for credential ${credential.id}`);
return res.status(400).json({ error: "Invalid credential ID" });
}
Comment on lines 38 to 54
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Auth check isn't strong enough, do we plan to fix it within this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that I just published my review comments.

For the auth check we first get the credential via the Salesforce instance URL. Then we check that the incoming Salesforce org ID matches what we have stored in the credential. The probability of guessing the right combination is very very low.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is still not the correct way to authorize in the long run because if I have obtained those somehow, I have no-expiry access to make changes to attributes of an organization. This is mostly secure by obfuscation as I understand it.


const salesforceCredentialId = (credential.key as { id?: string } | null)?.id;

if (!salesforceCredentialId) {
log.error(`Missing SFDC id for credential ${credential.id}`);
return res.status(400).json({ error: "Invalid credential ID" });
}

let storedSfdcOrgId: string | undefined;
try {
storedSfdcOrgId = new URL(salesforceCredentialId).pathname.split("/")[2];
} catch {
log.error(`Invalid SFDC credential URL format for credential ${credential.id}`);
return res.status(400).json({ error: "Invalid credential format" });
}

if (storedSfdcOrgId !== sfdcOrgId) {
log.error(`Mismatched orgId ${sfdcOrgId} for credential ${credential.id}`);
return res.status(400).json({ error: "Invalid org ID" });
}

const userRepository = new UserRepository(prisma);
const user = await userRepository.findByEmailAndTeamId({
email,
teamId: credential.teamId,
});

if (!user) {
log.error(
`User not found for email ${email} and teamId ${credential.teamId}`
);
return res.status(400).json({ error: "Invalid user" });
}

const organizationId = credential.teamId;

const integrationAttributeSyncService = getIntegrationAttributeSyncService();

const integrationAttributeSyncs =
await integrationAttributeSyncService.getAllByCredentialId(credential.id);

const attributeSyncRuleService = getAttributeSyncRuleService();
const attributeSyncFieldMappingService =
getAttributeSyncFieldMappingService();

const results = await Promise.allSettled(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any errors here are not being logged

integrationAttributeSyncs.map(async (sync) => {
// Only check rule if one exists - skip sync only if rule returns false
if (sync.attributeSyncRule) {
const shouldSyncApplyToUser =
await attributeSyncRuleService.shouldSyncApplyToUser({
user: {
id: user.id,
organizationId,
},
attributeSyncRule: sync.attributeSyncRule.rule,
Comment on lines +105 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I shared the concern in the previous PR, but it is important enough to be mentioned here again.

We would probably need batch processing methods here to work for multiple users, soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In each request we would only receive a single user to update. Where we are batching though is creating new attribute options and assigning those attributes to the user.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I meant that in the call out we should send a batch of users as there is a limit in Queuable in the number of callouts we can make

That would mean that we would then need to handle a batch of users.

});

if (!shouldSyncApplyToUser) return;
}
Comment on lines +103 to +114
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration attribute syncs can be saved with no rules so they apply to everyone in the org


// Salesforce multi-select picklists use `;` as separator, convert to `,` for the service
const integrationFields = Object.fromEntries(
Object.entries(changedFields as Record<string, unknown>)
.filter(([, value]) => value != null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are filtering out null values here. This could potentially ignore salesforce field changes that were explicitly setting the values to null. So, those changes won;'t be synced to Cal.com

.map(([key, value]) => [key, String(value).replaceAll(";", ",")])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement of ; to , should probably be delayed till we know the attribute type in which this value is going to be used.

This could potentially cause unexpected literal ; being replaced

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working with a customer on this and can confirm they don't have ; in their values. Salesforce also doesn't allow you to add ; in the value because it's used as a deliminator

);
Comment on lines +117 to +121
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Salesforce specifically separates values with ; so we normalize it to , before passing it to the service


await attributeSyncFieldMappingService.syncIntegrationFieldsToAttributes({
userId: user.id,
organizationId,
syncFieldMappings: sync.syncFieldMappings,
integrationFields,
});
})
);

const errors = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected"
);

if (errors.length > 0) {
log.error("Errors syncing user attributes", {
errors: errors.map((e) => e.reason),
});
}

return res.status(200).json({ success: true });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultdevhubusername": "DevHub"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
@isTest
private class CalComCalloutQueueableTest {

@isTest
static void testQueueableExecutionSuccess() {
CalComHttpMock.resetState();

List<CalComCalloutQueueable.UserChangePayload> payloads = createTestPayloads(1);

Test.setMock(HttpCalloutMock.class, new CalComHttpMock(200, '{"success": true}'));

Test.startTest();
CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads);
System.enqueueJob(queueable);
Test.stopTest();

System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected exactly one HTTP callout');
List<HttpRequest> requests = CalComHttpMock.getCapturedRequests();
System.assertEquals(1, requests.size(), 'Expected one captured request');
System.assertEquals('POST', requests[0].getMethod(), 'Request method should be POST');
}

@isTest
static void testQueueableExecutionWithMultiplePayloads() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This test creates 3 payloads but doesn't verify batching behavior. The underlying CalComCalloutQueueable.execute() makes a separate HTTP call per payload, which will hit Salesforce's ~100 callout-per-transaction limit with larger batches. Consider batching multiple payloads into a single request to /api/integrations/salesforce/user-sync and testing that behavior.

(Based on your team's feedback about batching payloads and Salesforce callout limits.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls, line 19:

<comment>This test creates 3 payloads but doesn't verify batching behavior. The underlying `CalComCalloutQueueable.execute()` makes a separate HTTP call per payload, which will hit Salesforce's ~100 callout-per-transaction limit with larger batches. Consider batching multiple payloads into a single request to `/api/integrations/salesforce/user-sync` and testing that behavior.

(Based on your team's feedback about batching payloads and Salesforce callout limits.) </comment>

<file context>
@@ -0,0 +1,97 @@
+    }
+
+    @isTest
+    static void testQueueableExecutionWithMultiplePayloads() {
+        List<CalComCalloutQueueable.UserChangePayload> payloads = createTestPayloads(3);
+
</file context>

CalComHttpMock.resetState();

List<CalComCalloutQueueable.UserChangePayload> payloads = createTestPayloads(3);

Test.setMock(HttpCalloutMock.class, new CalComHttpMock(200, '{"success": true}'));

Test.startTest();
CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads);
System.enqueueJob(queueable);
Test.stopTest();

System.assertEquals(3, CalComHttpMock.getCallCount(), 'Expected three HTTP callouts for three payloads');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test now enforces one HTTP callout per payload, which contradicts the requirement to batch payloads and risks hitting Salesforce’s 100-callout limit. Update the queueable logic (and this assertion) so multiple payloads are sent in a single request whenever possible.

(Based on your team's feedback about batching Salesforce payloads to stay within callout limits.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls, line 36:

<comment>This test now enforces one HTTP callout per payload, which contradicts the requirement to batch payloads and risks hitting Salesforce’s 100-callout limit. Update the queueable logic (and this assertion) so multiple payloads are sent in a single request whenever possible.

(Based on your team's feedback about batching Salesforce payloads to stay within callout limits.) </comment>

<file context>
@@ -26,11 +33,13 @@ private class CalComCalloutQueueableTest {
         Test.stopTest();
 
-        System.assert(true, 'Queueable executed with multiple payloads without exceptions');
+        System.assertEquals(3, CalComHttpMock.getCallCount(), 'Expected three HTTP callouts for three payloads');
     }
 
</file context>

}

@isTest
static void testQueueableExecutionFailureResponse() {
CalComHttpMock.resetState();

List<CalComCalloutQueueable.UserChangePayload> payloads = createTestPayloads(1);

Test.setMock(HttpCalloutMock.class, new CalComHttpMock(500, '{"error": "Internal Server Error"}'));

Test.startTest();
CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads);
System.enqueueJob(queueable);
Test.stopTest();

System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected HTTP callout even for failure response');
}

@isTest
static void testQueueableExecutionEmptyPayloads() {
CalComHttpMock.resetState();

List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();

Test.setMock(HttpCalloutMock.class, new CalComHttpMock());

Test.startTest();
CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads);
System.enqueueJob(queueable);
Test.stopTest();

System.assertEquals(0, CalComHttpMock.getCallCount(), 'Expected no HTTP callouts with empty payloads');
}

@isTest
static void testUserChangePayloadStructure() {
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
payload.salesforceUserId = UserInfo.getUserId();
payload.email = 'test@example.com';
payload.instanceUrl = 'https://test.salesforce.com';
payload.orgId = UserInfo.getOrganizationId();
payload.changedFields = new Map<String, Object>{'FirstName' => 'Test'};
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');

System.assertNotEquals(null, payload.salesforceUserId, 'salesforceUserId should be set');
System.assertEquals('test@example.com', payload.email, 'email should match');
System.assertNotEquals(null, payload.instanceUrl, 'instanceUrl should be set');
System.assertNotEquals(null, payload.orgId, 'orgId should be set');
System.assertEquals(1, payload.changedFields.size(), 'changedFields should have one entry');
System.assertNotEquals(null, payload.timestamp, 'timestamp should be set');
}

private static List<CalComCalloutQueueable.UserChangePayload> createTestPayloads(Integer count) {
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();

for (Integer i = 0; i < count; i++) {
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
payload.salesforceUserId = UserInfo.getUserId();
payload.email = 'test' + i + '@example.com';
payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm();
payload.orgId = UserInfo.getOrganizationId();
payload.changedFields = new Map<String, Object>{
'FirstName' => 'TestFirst' + i,
'LastName' => 'TestLast' + i
};
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');
payloads.add(payload);
}

return payloads;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>64.0</apiVersion>
<status>Active</status>
</ApexClass>
Loading
Loading