Skip to content

Commit 714a3d8

Browse files
ryancbahanclaude
andcommitted
Remove dead defaultAppConfigReverseTransform and add round-trip fidelity tests
Delete unreachable defaultAppConfigReverseTransform and its dead code path in resolveReverseAppConfigTransform. Add round-trip tests for all 9 config specs documenting forward/reverse transform behavior and known asymmetries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 21a577c commit 714a3d8

2 files changed

Lines changed: 267 additions & 46 deletions

File tree

packages/app/src/cli/models/extensions/specification.ts

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
259259
schema: spec.schema,
260260
appModuleFeatures,
261261
transformLocalToRemote: resolveAppConfigTransform(spec.transformConfig),
262-
transformRemoteToLocal: resolveReverseAppConfigTransform(spec.schema, spec.transformConfig),
262+
transformRemoteToLocal: resolveReverseAppConfigTransform(spec.transformConfig),
263263
experience: 'configuration',
264264
uidStrategy: spec.uidStrategy ?? 'single',
265265
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
@@ -294,14 +294,7 @@ function resolveAppConfigTransform(transformConfig: TransformationConfig | Custo
294294
}
295295
}
296296

297-
function resolveReverseAppConfigTransform<T>(
298-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
299-
schema: zod.ZodType<T, any, any>,
300-
transformConfig?: TransformationConfig | CustomTransformationConfig,
301-
) {
302-
if (!transformConfig)
303-
return (content: object) => defaultAppConfigReverseTransform(schema, content as {[key: string]: unknown})
304-
297+
function resolveReverseAppConfigTransform(transformConfig: TransformationConfig | CustomTransformationConfig) {
305298
if (Object.keys(transformConfig).includes('reverse')) {
306299
return (transformConfig as CustomTransformationConfig).reverse!
307300
} else {
@@ -348,43 +341,6 @@ function appConfigTransform(
348341
return transformedContent
349342
}
350343

351-
/**
352-
* Nest the content inside the first level objects expected by the local schema.
353-
* ```json
354-
* {
355-
* embedded = true
356-
* }
357-
* ```
358-
* will be nested after applying the proper schema:
359-
* ```json
360-
* {
361-
* pos: {
362-
* embedded = true
363-
* }
364-
* }
365-
* ```
366-
* @param content - The objet to be nested
367-
*
368-
* @returns The nested object
369-
*/
370-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
371-
function defaultAppConfigReverseTransform<T>(schema: zod.ZodType<T, any, any>, content: {[key: string]: unknown}) {
372-
return Object.keys(schema._def.shape()).reduce((result: {[key: string]: unknown}, key: string) => {
373-
let innerSchema = schema._def.shape()[key]
374-
if (innerSchema instanceof zod.ZodOptional) {
375-
innerSchema = innerSchema._def.innerType
376-
}
377-
if (innerSchema instanceof zod.ZodObject) {
378-
result[key] = defaultAppConfigReverseTransform(innerSchema, content)
379-
} else {
380-
if (content[key] !== undefined) result[key] = content[key]
381-
382-
delete content[key]
383-
}
384-
return result
385-
}, {})
386-
}
387-
388344
/**
389345
* Remove the first class fields from the config.
390346
*
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import brandingSpec from '../app_config_branding.js'
2+
import appHomeSpec from '../app_config_app_home.js'
3+
import appAccessSpec from '../app_config_app_access.js'
4+
import posSpec from '../app_config_point_of_sale.js'
5+
import appProxySpec from '../app_config_app_proxy.js'
6+
import webhookSpec from '../app_config_webhook.js'
7+
import webhookSubscriptionSpec from '../app_config_webhook_subscription.js'
8+
import privacyComplianceSpec from '../app_config_privacy_compliance_webhooks.js'
9+
import eventsSpec from '../app_config_events.js'
10+
import {AppConfiguration} from '../../../app/app.js'
11+
import {describe, expect, test} from 'vitest'
12+
13+
/**
14+
* Round-trip fidelity tests for all 9 config extension specs.
15+
*
16+
* For each spec, we test: reverse(forward(localInput, appConfig)) and compare to localInput.
17+
* Specs with TransformationConfig (path-based bijection) should round-trip exactly.
18+
* Specs with CustomTransformationConfig may have known asymmetries (documented inline).
19+
*/
20+
21+
const appConfigWithUrl = {
22+
application_url: 'https://example.com',
23+
} as unknown as AppConfiguration
24+
25+
const appConfigPlain = {scopes: ''} as unknown as AppConfiguration
26+
27+
function roundTrip(
28+
spec: {
29+
transformLocalToRemote?: (content: object, appConfig: AppConfiguration) => object
30+
transformRemoteToLocal?: (content: object) => object
31+
},
32+
localInput: object,
33+
appConfig: AppConfiguration = appConfigPlain,
34+
) {
35+
const remote = spec.transformLocalToRemote!(localInput, appConfig)
36+
return spec.transformRemoteToLocal!(remote)
37+
}
38+
39+
describe('spec transform round-trips', () => {
40+
// --- Path-based bijection specs (should round-trip exactly) ---
41+
42+
describe('branding', () => {
43+
test('round-trips exactly', () => {
44+
const local = {name: 'my-app', handle: 'my-handle'}
45+
expect(roundTrip(brandingSpec, local)).toEqual(local)
46+
})
47+
48+
test('round-trips with partial fields', () => {
49+
const local = {name: 'my-app'}
50+
expect(roundTrip(brandingSpec, local)).toEqual(local)
51+
})
52+
})
53+
54+
describe('app_home', () => {
55+
test('round-trips exactly', () => {
56+
const local = {
57+
application_url: 'https://example.com',
58+
embedded: true,
59+
app_preferences: {url: 'https://example.com/prefs'},
60+
}
61+
expect(roundTrip(appHomeSpec, local)).toEqual(local)
62+
})
63+
64+
test('round-trips without optional preferences', () => {
65+
const local = {application_url: 'https://example.com', embedded: false}
66+
expect(roundTrip(appHomeSpec, local)).toEqual(local)
67+
})
68+
})
69+
70+
describe('app_access', () => {
71+
test('round-trips exactly', () => {
72+
const local = {
73+
access: {admin: {direct_api_mode: 'online', embedded_app_direct_api_access: true}},
74+
access_scopes: {scopes: 'read_products,write_products', use_legacy_install_flow: false},
75+
auth: {redirect_urls: ['https://example.com/callback']},
76+
}
77+
expect(roundTrip(appAccessSpec, local)).toEqual(local)
78+
})
79+
80+
test('round-trips with minimal fields', () => {
81+
const local = {auth: {redirect_urls: ['https://example.com/callback']}}
82+
expect(roundTrip(appAccessSpec, local)).toEqual(local)
83+
})
84+
})
85+
86+
describe('point_of_sale', () => {
87+
test('round-trips exactly', () => {
88+
const local = {pos: {embedded: true}}
89+
expect(roundTrip(posSpec, local)).toEqual(local)
90+
})
91+
92+
test('round-trips when pos is absent', () => {
93+
const local = {}
94+
expect(roundTrip(posSpec, local)).toEqual(local)
95+
})
96+
})
97+
98+
// --- Custom transform specs (known asymmetries documented) ---
99+
100+
describe('app_proxy', () => {
101+
test('round-trips with absolute URL', () => {
102+
const local = {app_proxy: {url: 'https://proxy.example.com/path', subpath: 'apps', prefix: 'my-app'}}
103+
expect(roundTrip(appProxySpec, local, appConfigWithUrl)).toEqual(local)
104+
})
105+
106+
test('relative URL becomes absolute after round-trip', () => {
107+
// Asymmetry: forward prepends application_url to relative URLs, reverse does not strip it back
108+
const local = {app_proxy: {url: '/proxy', subpath: 'apps', prefix: 'my-app'}}
109+
const result = roundTrip(appProxySpec, local, appConfigWithUrl)
110+
111+
expect(result).toEqual({
112+
app_proxy: {url: 'https://example.com/proxy', subpath: 'apps', prefix: 'my-app'},
113+
})
114+
})
115+
116+
test('empty config produces empty forward, reverse wraps in app_proxy', () => {
117+
const remote = appProxySpec.transformLocalToRemote!({}, appConfigWithUrl)
118+
expect(remote).toEqual({})
119+
120+
const reversed = appProxySpec.transformRemoteToLocal!(remote)
121+
// Reverse always wraps in app_proxy, even with undefined fields
122+
expect(reversed).toEqual({
123+
app_proxy: {url: undefined, subpath: undefined, prefix: undefined},
124+
})
125+
})
126+
})
127+
128+
describe('webhooks', () => {
129+
test('round-trips api_version', () => {
130+
// Asymmetry: forward extracts only api_version, subscriptions are intentionally dropped
131+
// (handled by webhook_subscription spec)
132+
const local = {webhooks: {api_version: '2024-01'}}
133+
expect(roundTrip(webhookSpec, local)).toEqual(local)
134+
})
135+
136+
test('subscriptions are dropped during round-trip', () => {
137+
const local = {
138+
webhooks: {
139+
api_version: '2024-01',
140+
subscriptions: [{topics: ['products/create'], uri: 'https://example.com/webhooks'}],
141+
},
142+
}
143+
const result = roundTrip(webhookSpec, local)
144+
// Only api_version survives — subscriptions handled by webhook_subscription spec
145+
expect(result).toEqual({webhooks: {api_version: '2024-01'}})
146+
})
147+
})
148+
149+
describe('webhook_subscription', () => {
150+
test('single topic wraps into topics array', () => {
151+
// Asymmetry: forward produces {topic: 'x'} (single), reverse produces {topics: ['x']} (array)
152+
const local = {topics: ['products/create'], uri: 'https://example.com/webhooks'}
153+
const remote = webhookSubscriptionSpec.transformLocalToRemote!(local, appConfigPlain)
154+
155+
// Forward just passes through (no relative URL to resolve)
156+
expect(remote).toEqual({topics: ['products/create'], uri: 'https://example.com/webhooks'})
157+
158+
// Reverse wraps in webhooks.subscriptions structure with topic → topics
159+
const reversed = webhookSubscriptionSpec.transformRemoteToLocal!(remote)
160+
expect(reversed).toEqual({
161+
webhooks: {
162+
subscriptions: [{topics: ['products/create'], uri: 'https://example.com/webhooks'}],
163+
},
164+
})
165+
})
166+
167+
test('relative URI becomes absolute after forward', () => {
168+
const local = {topics: ['products/create'], uri: '/webhooks'}
169+
const remote = webhookSubscriptionSpec.transformLocalToRemote!(local, appConfigWithUrl)
170+
171+
expect(remote).toEqual({topics: ['products/create'], uri: 'https://example.com/webhooks'})
172+
})
173+
})
174+
175+
describe('privacy_compliance_webhooks', () => {
176+
test('round-trips compliance URLs', () => {
177+
const local = {
178+
webhooks: {
179+
api_version: '2024-01',
180+
subscriptions: [
181+
{compliance_topics: ['customers/data_request'], uri: 'https://example.com/data-request'},
182+
{compliance_topics: ['customers/redact'], uri: 'https://example.com/customers-redact'},
183+
{compliance_topics: ['shop/redact'], uri: 'https://example.com/shop-redact'},
184+
],
185+
},
186+
}
187+
188+
const remote = privacyComplianceSpec.transformLocalToRemote!(local, appConfigPlain)
189+
expect(remote).toEqual({
190+
api_version: '2024-01',
191+
customers_data_request_url: 'https://example.com/data-request',
192+
customers_redact_url: 'https://example.com/customers-redact',
193+
shop_redact_url: 'https://example.com/shop-redact',
194+
})
195+
196+
const reversed = privacyComplianceSpec.transformRemoteToLocal!(remote)
197+
// Reverse reconstructs subscriptions from flat URLs, sorted by URI
198+
expect(reversed).toEqual({
199+
webhooks: {
200+
subscriptions: [
201+
{compliance_topics: ['customers/redact'], uri: 'https://example.com/customers-redact'},
202+
{compliance_topics: ['customers/data_request'], uri: 'https://example.com/data-request'},
203+
{compliance_topics: ['shop/redact'], uri: 'https://example.com/shop-redact'},
204+
],
205+
privacy_compliance: undefined,
206+
},
207+
})
208+
})
209+
210+
test('relative URIs become absolute after forward', () => {
211+
const local = {
212+
webhooks: {
213+
api_version: '2024-01',
214+
subscriptions: [{compliance_topics: ['customers/redact'], uri: '/customers-redact'}],
215+
},
216+
}
217+
const remote = privacyComplianceSpec.transformLocalToRemote!(local, appConfigWithUrl)
218+
expect(remote).toEqual({
219+
api_version: '2024-01',
220+
customers_redact_url: 'https://example.com/customers-redact',
221+
})
222+
})
223+
224+
test('empty webhooks produce empty result', () => {
225+
const local = {webhooks: {api_version: '2024-01', subscriptions: []}}
226+
const remote = privacyComplianceSpec.transformLocalToRemote!(local, appConfigPlain)
227+
// No compliance URLs → empty object
228+
expect(remote).toEqual({})
229+
})
230+
})
231+
232+
describe('events', () => {
233+
test('round-trips with absolute URIs (identifier stripped)', () => {
234+
// Asymmetry: reverse strips server-managed `identifier` field
235+
const local = {events: {api_version: '2024-01', subscription: [{uri: 'https://example.com/events'}]}}
236+
237+
const remote = eventsSpec.transformLocalToRemote!(local, appConfigPlain)
238+
expect(remote).toEqual(local)
239+
240+
// Simulate server adding identifier
241+
const remoteWithIdentifier = {
242+
events: {
243+
api_version: '2024-01',
244+
subscription: [{uri: 'https://example.com/events', identifier: 'evt_123'}],
245+
},
246+
}
247+
const reversed = eventsSpec.transformRemoteToLocal!(remoteWithIdentifier)
248+
// identifier is stripped
249+
expect(reversed).toEqual({events: {api_version: '2024-01', subscription: [{uri: 'https://example.com/events'}]}})
250+
})
251+
252+
test('relative URI becomes absolute after forward', () => {
253+
const local = {events: {api_version: '2024-01', subscription: [{uri: '/events'}]}}
254+
const remote = eventsSpec.transformLocalToRemote!(local, appConfigWithUrl)
255+
expect(remote).toEqual({
256+
events: {api_version: '2024-01', subscription: [{uri: 'https://example.com/events'}]},
257+
})
258+
})
259+
260+
test('round-trips without subscriptions', () => {
261+
const local = {events: {api_version: '2024-01'}}
262+
expect(roundTrip(eventsSpec, local)).toEqual(local)
263+
})
264+
})
265+
})

0 commit comments

Comments
 (0)