Skip to content

Commit 228feb3

Browse files
authored
Add E2E shell tests with SLAS auto-tenant creation and job body support (#27)
* e2e shell script testing * fix issues with ods timing and general error logging in json mode * handle ods create output * better structured logging in ods create * support raw request for job run for system jobs * fix e2e test; valid site archive fixture * updating docs and skills for new job run capabilities * slas should create tenant if doesn't exist * fixing site import for site catalog; e2e tests updated; new workflow * linting; restore deletion * more lint
1 parent 40dbcce commit 228feb3

File tree

20 files changed

+707
-60
lines changed

20 files changed

+707
-60
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: E2E Shell Tests
2+
3+
on:
4+
schedule:
5+
- cron: '0 3 * * *' # Run at 3 AM UTC daily
6+
workflow_dispatch:
7+
8+
jobs:
9+
e2e-shell-tests:
10+
runs-on: ubuntu-latest
11+
environment: e2e-dev
12+
timeout-minutes: 30
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: '24'
19+
20+
- name: Check for required secrets and vars
21+
id: check-secrets
22+
env:
23+
SFCC_CLIENT_ID: ${{ vars.SFCC_CLIENT_ID }}
24+
SFCC_CLIENT_SECRET: ${{ secrets.SFCC_CLIENT_SECRET }}
25+
TEST_REALM: ${{ vars.TEST_REALM }}
26+
SFCC_ACCOUNT_MANAGER_HOST: ${{ vars.SFCC_ACCOUNT_MANAGER_HOST }}
27+
SFCC_SANDBOX_API_HOST: ${{ vars.SFCC_SANDBOX_API_HOST }}
28+
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
29+
run: |
30+
missing=""
31+
[ -z "$SFCC_CLIENT_ID" ] && missing="$missing SFCC_CLIENT_ID"
32+
[ -z "$SFCC_CLIENT_SECRET" ] && missing="$missing SFCC_CLIENT_SECRET"
33+
[ -z "$TEST_REALM" ] && missing="$missing TEST_REALM"
34+
[ -z "$SFCC_ACCOUNT_MANAGER_HOST" ] && missing="$missing SFCC_ACCOUNT_MANAGER_HOST"
35+
[ -z "$SFCC_SANDBOX_API_HOST" ] && missing="$missing SFCC_SANDBOX_API_HOST"
36+
[ -z "$SFCC_SHORTCODE" ] && missing="$missing SFCC_SHORTCODE"
37+
38+
if [ -z "$missing" ]; then
39+
echo "has-secrets=true" >> $GITHUB_OUTPUT
40+
else
41+
echo "has-secrets=false" >> $GITHUB_OUTPUT
42+
echo "E2E shell tests skipped - missing required variables:$missing" >> $GITHUB_STEP_SUMMARY
43+
fi
44+
45+
- name: Setup pnpm
46+
if: steps.check-secrets.outputs.has-secrets == 'true'
47+
uses: pnpm/action-setup@v4
48+
with:
49+
version: 10.17.1
50+
51+
- name: Get pnpm store directory
52+
if: steps.check-secrets.outputs.has-secrets == 'true'
53+
shell: bash
54+
run: |
55+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
56+
57+
- name: Setup pnpm cache
58+
if: steps.check-secrets.outputs.has-secrets == 'true'
59+
uses: actions/cache@v4
60+
with:
61+
path: ${{ env.STORE_PATH }}
62+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
63+
restore-keys: |
64+
${{ runner.os }}-pnpm-store-
65+
66+
- name: Install dependencies
67+
if: steps.check-secrets.outputs.has-secrets == 'true'
68+
run: pnpm install --frozen-lockfile
69+
70+
- name: Build packages
71+
if: steps.check-secrets.outputs.has-secrets == 'true'
72+
run: pnpm -r run build
73+
74+
- name: Run E2E Shell Tests
75+
if: steps.check-secrets.outputs.has-secrets == 'true'
76+
env:
77+
SFCC_CLIENT_ID: ${{ vars.SFCC_CLIENT_ID }}
78+
SFCC_CLIENT_SECRET: ${{ secrets.SFCC_CLIENT_SECRET }}
79+
SFCC_ACCOUNT_MANAGER_HOST: ${{ vars.SFCC_ACCOUNT_MANAGER_HOST }}
80+
SFCC_SANDBOX_API_HOST: ${{ vars.SFCC_SANDBOX_API_HOST }}
81+
SFCC_SHORTCODE: ${{ vars.SFCC_SHORTCODE }}
82+
TEST_REALM: ${{ vars.TEST_REALM }}
83+
run: |
84+
echo "Running E2E shell tests with realm: ${TEST_REALM}"
85+
cd packages/b2c-cli
86+
./test/functional/e2e_cli_test.sh

docs/cli/jobs.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@ In addition to [global flags](./index#global-flags):
2727
| `--wait`, `-w` | Wait for job to complete | `false` |
2828
| `--timeout`, `-t` | Timeout in seconds when waiting | No timeout |
2929
| `--param`, `-P` | Job parameter in format "name=value" (repeatable) | |
30+
| `--body`, `-B` | Raw JSON request body (for system jobs with non-standard schemas) | |
3031
| `--no-wait-running` | Do not wait for running job to finish before starting | `false` |
3132
| `--show-log` | Show job log on failure | `true` |
3233

34+
Note: `--param` and `--body` are mutually exclusive.
35+
3336
### Examples
3437

3538
```bash
@@ -42,13 +45,25 @@ b2c job run my-custom-job --wait
4245
# Execute with timeout
4346
b2c job run my-custom-job --wait --timeout 600
4447

45-
# Execute with parameters
48+
# Execute with parameters (standard jobs)
4649
b2c job run my-custom-job -P "SiteScope={\"all_storefront_sites\":true}" -P OtherParam=value
4750

4851
# Output as JSON
4952
b2c job run my-custom-job --wait --json
5053
```
5154

55+
### System Jobs with Custom Request Bodies
56+
57+
Some system jobs (like search indexing) use non-standard request schemas that don't follow the `parameters` array format. Use `--body` to provide a raw JSON request body:
58+
59+
```bash
60+
# Run search index job for specific sites
61+
b2c job run sfcc-search-index-product-full-update --wait --body '{"site_scope":["RefArch","SiteGenesis"]}'
62+
63+
# Run search index job for a single site
64+
b2c job run sfcc-search-index-product-full-update --wait --body '{"site_scope":["RefArch"]}'
65+
```
66+
5267
### Authentication
5368

5469
This command requires OAuth authentication with OCAPI permissions for the `/jobs` resource.

docs/cli/slas.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,18 @@ b2c slas client create [CLIENTID] --tenant-id <TENANT_ID> --channels <CHANNELS>
102102

103103
### Flags
104104

105-
| Flag | Description | Required |
106-
|------|-------------|----------|
107-
| `--tenant-id` | SLAS tenant ID (organization ID) | Yes |
108-
| `--channels` | Site IDs/channels (comma-separated) | Yes |
109-
| `--redirect-uri` | Redirect URIs (comma-separated) | Yes |
110-
| `--name` | Display name for the client | No |
111-
| `--scopes` | OAuth scopes for the client (comma-separated) | No |
112-
| `--default-scopes` | Use default shopper scopes | No |
113-
| `--callback-uri` | Callback URIs for passwordless login | No |
114-
| `--secret` | Client secret (generated if omitted) | No |
115-
| `--public` | Create a public client (default is private) | No |
105+
| Flag | Description | Default |
106+
|------|-------------|---------|
107+
| `--tenant-id` | SLAS tenant ID (organization ID) | Required |
108+
| `--channels` | Site IDs/channels (comma-separated) | Required |
109+
| `--redirect-uri` | Redirect URIs (comma-separated) | Required |
110+
| `--name` | Display name for the client | Auto-generated |
111+
| `--scopes` | OAuth scopes for the client (comma-separated) | |
112+
| `--default-scopes` | Use default shopper scopes | `false` |
113+
| `--callback-uri` | Callback URIs for passwordless login | |
114+
| `--secret` | Client secret (generated if omitted) | Auto-generated |
115+
| `--public` | Create a public client (default is private) | `false` |
116+
| `--[no-]create-tenant` | Automatically create tenant if it doesn't exist | `true` |
116117

117118
### Examples
118119

@@ -150,6 +151,7 @@ b2c slas client create --tenant-id abcd_123 \
150151
- If `--secret` is not provided for a private client, one will be generated
151152
- The generated secret is only shown once during creation
152153
- Use `--default-scopes` for common shopper API access scopes
154+
- By default, the tenant is automatically created if it doesn't exist. Use `--no-create-tenant` to disable this behavior if you prefer to manage tenants separately
153155

154156
---
155157

packages/b2c-cli/src/commands/job/run.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default class JobRun extends JobCommand<typeof JobRun> {
3030
'<%= config.bin %> <%= command.id %> my-custom-job --wait',
3131
String.raw`<%= config.bin %> <%= command.id %> my-custom-job -P "SiteScope={\"all_storefront_sites\":true}" -P OtherParam=value`,
3232
'<%= config.bin %> <%= command.id %> my-custom-job --wait --timeout 600',
33+
String.raw`<%= config.bin %> <%= command.id %> sfcc-search-index-product-full-update --body '{"site_scope":{"named_sites":["RefArch"]}}'`,
3334
];
3435

3536
static flags = {
@@ -48,6 +49,12 @@ export default class JobRun extends JobCommand<typeof JobRun> {
4849
description: 'Job parameter in format "name=value" (use -P multiple times for multiple params)',
4950
multiple: true,
5051
multipleNonGreedy: true,
52+
exclusive: ['body'],
53+
}),
54+
body: Flags.string({
55+
char: 'B',
56+
description: 'Raw JSON request body (for system jobs with non-standard schemas)',
57+
exclusive: ['param'],
5158
}),
5259
'no-wait-running': Flags.boolean({
5360
description: 'Do not wait for running job to finish before starting',
@@ -63,10 +70,11 @@ export default class JobRun extends JobCommand<typeof JobRun> {
6370
this.requireOAuthCredentials();
6471

6572
const {jobId} = this.args;
66-
const {wait, timeout, param, 'no-wait-running': noWaitRunning, 'show-log': showLog} = this.flags;
73+
const {wait, timeout, param, body, 'no-wait-running': noWaitRunning, 'show-log': showLog} = this.flags;
6774

68-
// Parse parameters
75+
// Parse parameters or body
6976
const parameters = this.parseParameters(param || []);
77+
const rawBody = body ? this.parseBody(body) : undefined;
7078

7179
this.log(
7280
t('commands.job.run.executing', 'Executing job {{jobId}} on {{hostname}}...', {
@@ -78,7 +86,8 @@ export default class JobRun extends JobCommand<typeof JobRun> {
7886
let execution: JobExecution;
7987
try {
8088
execution = await executeJob(this.instance, jobId, {
81-
parameters,
89+
parameters: rawBody ? undefined : parameters,
90+
body: rawBody,
8291
waitForRunning: !noWaitRunning,
8392
});
8493
} catch (error) {
@@ -147,6 +156,14 @@ export default class JobRun extends JobCommand<typeof JobRun> {
147156
return execution;
148157
}
149158

159+
private parseBody(body: string): Record<string, unknown> {
160+
try {
161+
return JSON.parse(body) as Record<string, unknown>;
162+
} catch {
163+
this.error(t('commands.job.run.invalidBody', 'Invalid JSON body: {{body}}', {body}));
164+
}
165+
}
166+
150167
private parseParameters(params: string[]): Array<{name: string; value: string}> {
151168
return params.map((p) => {
152169
const eqIndex = p.indexOf('=');

packages/b2c-cli/src/commands/ods/create.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
152152
let sandbox = result.data.data;
153153

154154
this.log('');
155-
this.log(t('commands.ods.create.success', 'Sandbox created successfully!'));
155+
this.logger.info({sandboxId: sandbox.id}, t('commands.ods.create.success', 'Sandbox created successfully'));
156156

157157
if (wait && sandbox.id) {
158158
this.log('');
@@ -256,6 +256,9 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
256256

257257
this.log(t('commands.ods.create.waiting', 'Waiting for sandbox to be ready...'));
258258

259+
// Initial delay before first poll to allow the sandbox to be registered in the API
260+
await this.sleep(2000);
261+
259262
while (true) {
260263
// Check for timeout
261264
if (timeoutSeconds > 0 && Date.now() - startTime > timeoutMs) {
@@ -286,12 +289,8 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
286289

287290
// Log current state on each poll
288291
const elapsed = Math.round((Date.now() - startTime) / 1000);
289-
this.log(
290-
t('commands.ods.create.stateChange', '[{{elapsed}}s] State: {{state}}', {
291-
elapsed: String(elapsed),
292-
state: currentState || 'unknown',
293-
}),
294-
);
292+
const state = currentState || 'unknown';
293+
this.logger.info({sandboxId, elapsed, state}, `[${elapsed}s] State: ${state}`);
295294

296295
// Check for terminal states
297296
if (currentState && TERMINAL_STATES.has(currentState)) {
@@ -306,7 +305,7 @@ export default class OdsCreate extends OdsCommand<typeof OdsCreate> {
306305
}
307306
case 'started': {
308307
this.log('');
309-
this.log(t('commands.ods.create.ready', 'Sandbox is now ready!'));
308+
this.logger.info({sandboxId}, t('commands.ods.create.ready', 'Sandbox is now ready'));
310309
break;
311310
}
312311
}

packages/b2c-cli/src/commands/slas/client/create.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
9898
description: 'Create a public client (default is private)',
9999
default: false,
100100
}),
101+
'create-tenant': Flags.boolean({
102+
description: 'Automatically create tenant if it does not exist',
103+
default: true,
104+
allowNo: true,
105+
}),
101106
};
102107

103108
async run(): Promise<ClientOutput> {
@@ -113,6 +118,7 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
113118
'callback-uri': callbackUri,
114119
secret,
115120
public: isPublic,
121+
'create-tenant': createTenant,
116122
} = this.flags;
117123

118124
// Validate that either --scopes or --default-scopes is provided
@@ -140,6 +146,11 @@ export default class SlasClientCreate extends SlasClientCommand<typeof SlasClien
140146

141147
const slasClient = this.getSlasClient();
142148

149+
// Ensure tenant exists before creating client (if enabled)
150+
if (createTenant) {
151+
await this.ensureTenantExists(slasClient, tenantId);
152+
}
153+
143154
// Build body - secret should only be included for private clients
144155
const body: Record<string, unknown> = {
145156
clientId,

packages/b2c-cli/src/utils/slas/client.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,73 @@ export abstract class SlasClientCommand<T extends typeof Command> extends OAuthC
109109
}),
110110
};
111111

112+
/**
113+
* Ensure tenant exists, creating it if necessary.
114+
* This is required before creating SLAS clients.
115+
*/
116+
protected async ensureTenantExists(slasClient: SlasClient, tenantId: string): Promise<void> {
117+
// Try to get the tenant first
118+
const {error, response} = await slasClient.GET('/tenants/{tenantId}', {
119+
params: {
120+
path: {tenantId},
121+
},
122+
});
123+
124+
// If tenant exists, we're done
125+
if (!error) {
126+
return;
127+
}
128+
129+
// Check if this is a "tenant not found" error (SLAS returns 400 with TenantNotFoundException)
130+
const isTenantNotFound =
131+
response.status === 404 ||
132+
(response.status === 400 &&
133+
typeof error === 'object' &&
134+
error !== null &&
135+
'exception_name' in error &&
136+
(error as {exception_name?: string}).exception_name === 'TenantNotFoundException');
137+
138+
// If it's not a tenant-not-found error, something else went wrong
139+
if (!isTenantNotFound) {
140+
this.error(
141+
t('commands.slas.client.create.tenantError', 'Failed to check tenant: {{message}}', {
142+
message: formatApiError(error),
143+
}),
144+
);
145+
}
146+
147+
// Tenant doesn't exist, create it with placeholder values
148+
if (!this.jsonEnabled()) {
149+
this.log(t('commands.slas.client.create.creatingTenant', 'Creating SLAS tenant {{tenantId}}...', {tenantId}));
150+
}
151+
152+
const {error: createError} = await slasClient.PUT('/tenants/{tenantId}', {
153+
params: {
154+
path: {tenantId},
155+
},
156+
body: {
157+
tenantId,
158+
merchantName: 'B2C CLI Tenant',
159+
description: 'Auto-created by b2c-cli',
160+
contact: 'B2C CLI',
161+
emailAddress: 'noreply@example.com',
162+
phoneNo: '+1 000-000-0000',
163+
},
164+
});
165+
166+
if (createError) {
167+
this.error(
168+
t('commands.slas.client.create.tenantCreateError', 'Failed to create tenant: {{message}}', {
169+
message: formatApiError(createError),
170+
}),
171+
);
172+
}
173+
174+
if (!this.jsonEnabled()) {
175+
this.log(t('commands.slas.client.create.tenantCreated', 'SLAS tenant created successfully.'));
176+
}
177+
}
178+
112179
/**
113180
* Get the SLAS client, ensuring short code is configured.
114181
*/

0 commit comments

Comments
 (0)