@igstack/app-catalog-test-kit 0.18.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +51 -0
  2. package/dist/esm/harness/MockBackendVerifier.d.ts +8 -0
  3. package/dist/esm/harness/MockBackendVerifier.js +15 -0
  4. package/dist/esm/harness/MockBackendVerifier.js.map +1 -0
  5. package/dist/esm/harness/given.d.ts +23 -0
  6. package/dist/esm/harness/given.js +144 -0
  7. package/dist/esm/harness/given.js.map +1 -0
  8. package/dist/esm/index.d.ts +38 -0
  9. package/dist/esm/index.js +45 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/mock-backend/BrowserStateCfg.d.ts +18 -0
  12. package/dist/esm/mock-backend/BrowserStateCfg.js +33 -0
  13. package/dist/esm/mock-backend/BrowserStateCfg.js.map +1 -0
  14. package/dist/esm/mock-backend/MockBackendConfigurer.d.ts +17 -0
  15. package/dist/esm/mock-backend/MockBackendConfigurer.js +80 -0
  16. package/dist/esm/mock-backend/MockBackendConfigurer.js.map +1 -0
  17. package/dist/esm/mock-backend/MockDb.d.ts +18 -0
  18. package/dist/esm/mock-backend/MockDb.js +56 -0
  19. package/dist/esm/mock-backend/MockDb.js.map +1 -0
  20. package/dist/esm/mock-backend/MockService.d.ts +10 -0
  21. package/dist/esm/mock-backend/MockService.js +16 -0
  22. package/dist/esm/mock-backend/MockService.js.map +1 -0
  23. package/dist/esm/mock-backend/MockUserContext.d.ts +11 -0
  24. package/dist/esm/mock-backend/MockUserContext.js +38 -0
  25. package/dist/esm/mock-backend/MockUserContext.js.map +1 -0
  26. package/dist/esm/mock-backend/magazines.d.ts +22 -0
  27. package/dist/esm/mock-backend/magazines.js +186 -0
  28. package/dist/esm/mock-backend/magazines.js.map +1 -0
  29. package/dist/esm/mock-network/NetworkCatalog.d.ts +12 -0
  30. package/dist/esm/mock-network/NetworkCatalog.js +24 -0
  31. package/dist/esm/mock-network/NetworkCatalog.js.map +1 -0
  32. package/dist/esm/mock-network/NetworkConfigurerCfg.d.ts +13 -0
  33. package/dist/esm/mock-network/NetworkConfigurerCfg.js +16 -0
  34. package/dist/esm/mock-network/NetworkConfigurerCfg.js.map +1 -0
  35. package/dist/esm/mock-network/SharedNetwork.d.ts +13 -0
  36. package/dist/esm/mock-network/SharedNetwork.js +149 -0
  37. package/dist/esm/mock-network/SharedNetwork.js.map +1 -0
  38. package/dist/esm/mock-network/errorFactories.d.ts +11 -0
  39. package/dist/esm/mock-network/errorFactories.js +22 -0
  40. package/dist/esm/mock-network/errorFactories.js.map +1 -0
  41. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.d.ts +3 -0
  42. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js +17 -0
  43. package/dist/esm/mock-network/makeNetworkReplyWithCatalog.js.map +1 -0
  44. package/dist/esm/setup/polyfillLocalStorage.d.ts +0 -0
  45. package/dist/esm/setup/polyfillLocalStorage.js +25 -0
  46. package/dist/esm/setup/polyfillLocalStorage.js.map +1 -0
  47. package/dist/esm/setup/svgMock.d.ts +1 -0
  48. package/dist/esm/setup/testSetup.d.ts +0 -0
  49. package/dist/esm/setup/testSetup.js +58 -0
  50. package/dist/esm/setup/testSetup.js.map +1 -0
  51. package/dist/esm/tools/AppDetailTools.d.ts +63 -0
  52. package/dist/esm/tools/AppDetailTools.js +180 -0
  53. package/dist/esm/tools/AppDetailTools.js.map +1 -0
  54. package/dist/esm/tools/BrowserState.d.ts +25 -0
  55. package/dist/esm/tools/BrowserState.js +30 -0
  56. package/dist/esm/tools/BrowserState.js.map +1 -0
  57. package/dist/esm/tools/CatalogTools.d.ts +67 -0
  58. package/dist/esm/tools/CatalogTools.js +202 -0
  59. package/dist/esm/tools/CatalogTools.js.map +1 -0
  60. package/dist/esm/tools/ErrorTools.d.ts +15 -0
  61. package/dist/esm/tools/ErrorTools.js +34 -0
  62. package/dist/esm/tools/ErrorTools.js.map +1 -0
  63. package/dist/esm/tools/GalleryTools.d.ts +6 -0
  64. package/dist/esm/tools/GalleryTools.js +22 -0
  65. package/dist/esm/tools/GalleryTools.js.map +1 -0
  66. package/dist/esm/tools/suppressConsole.d.ts +19 -0
  67. package/dist/esm/tools/suppressConsole.js +44 -0
  68. package/dist/esm/tools/suppressConsole.js.map +1 -0
  69. package/package.json +87 -0
  70. package/src/harness/MockBackendVerifier.ts +14 -0
  71. package/src/harness/given.tsx +212 -0
  72. package/src/index.ts +44 -0
  73. package/src/mock-backend/BrowserStateCfg.ts +35 -0
  74. package/src/mock-backend/MockBackendConfigurer.ts +97 -0
  75. package/src/mock-backend/MockDb.ts +66 -0
  76. package/src/mock-backend/MockService.ts +18 -0
  77. package/src/mock-backend/MockUserContext.ts +42 -0
  78. package/src/mock-backend/magazines.ts +245 -0
  79. package/src/mock-network/NetworkCatalog.ts +30 -0
  80. package/src/mock-network/NetworkConfigurerCfg.ts +24 -0
  81. package/src/mock-network/SharedNetwork.ts +100 -0
  82. package/src/mock-network/errorFactories.ts +28 -0
  83. package/src/mock-network/makeNetworkReplyWithCatalog.ts +19 -0
  84. package/src/setup/polyfillLocalStorage.ts +34 -0
  85. package/src/setup/svgMock.tsx +3 -0
  86. package/src/setup/testSetup.ts +58 -0
  87. package/src/tools/AppDetailTools.ts +240 -0
  88. package/src/tools/BrowserState.ts +41 -0
  89. package/src/tools/CatalogTools.ts +254 -0
  90. package/src/tools/ErrorTools.ts +49 -0
  91. package/src/tools/GalleryTools.ts +26 -0
  92. package/src/tools/suppressConsole.ts +66 -0
@@ -0,0 +1,97 @@
1
+ import type {
2
+ AppApprovalMethod,
3
+ GroupingTagDefinition,
4
+ Resource,
5
+ } from '@igstack/app-catalog-backend-core'
6
+ import type { MockDb } from './MockDb'
7
+ import type { MockUserContext, UserConfig } from './MockUserContext'
8
+
9
+ let counter = 0
10
+
11
+ function nextId(): string {
12
+ counter++
13
+ return `test-id-${counter}`
14
+ }
15
+
16
+ function nextSlug(): string {
17
+ return `app-${counter}`
18
+ }
19
+
20
+ export class MockBackendConfigurer {
21
+ constructor(
22
+ readonly db: MockDb,
23
+ readonly userContext: MockUserContext,
24
+ ) {}
25
+
26
+ withApp(overrides?: Partial<Resource>): Resource {
27
+ const id = overrides?.id ?? nextId()
28
+ const app: Resource = {
29
+ id,
30
+ slug: overrides?.slug ?? nextSlug(),
31
+ displayName: overrides?.displayName ?? `App ${counter}`,
32
+ description: overrides?.description,
33
+ tags: overrides?.tags ?? [],
34
+ screenshotIds: overrides?.screenshotIds ?? [],
35
+ ...overrides,
36
+ }
37
+ this.db.upsertResource(app)
38
+ return app
39
+ }
40
+
41
+ withTag(overrides?: Partial<GroupingTagDefinition>): GroupingTagDefinition {
42
+ const def: GroupingTagDefinition = {
43
+ prefix: overrides?.prefix ?? `tag-${nextId()}`,
44
+ displayName: overrides?.displayName ?? 'Tag',
45
+ description: overrides?.description ?? '',
46
+ values: overrides?.values ?? [],
47
+ ...overrides,
48
+ }
49
+ this.db.setTagDefinitions([...this.db.tagsDefinitions, def])
50
+ return def
51
+ }
52
+
53
+ withApprovalMethod(
54
+ overrides?: Partial<AppApprovalMethod>,
55
+ ): AppApprovalMethod {
56
+ const slug = overrides?.slug ?? `method-${nextId()}`
57
+ const method = {
58
+ slug,
59
+ type: 'service' as const,
60
+ displayName: overrides?.displayName ?? 'Approval Method',
61
+ config: {},
62
+ ...overrides,
63
+ } as AppApprovalMethod
64
+ this.db.setApprovalMethods([...this.db.approvalMethods, method])
65
+ return method
66
+ }
67
+
68
+ withSubResource(
69
+ overrides: Partial<Resource> & { appSlug: string },
70
+ ): Resource {
71
+ const appSlug = overrides.appSlug
72
+ const id = overrides.id ?? nextId()
73
+ const resource: Resource = {
74
+ id,
75
+ slug: overrides.slug ?? `sr-${id}`,
76
+ displayName: overrides.displayName ?? `Sub Resource ${counter}`,
77
+ aliases: overrides.aliases ?? [],
78
+ approverSlugs: overrides.approverSlugs ?? [],
79
+ parentSlug: appSlug,
80
+ tier: overrides.tier,
81
+ ownerPersonSlug: overrides.ownerPersonSlug,
82
+ accessRequest: overrides.accessRequest,
83
+ description: overrides.description,
84
+ }
85
+ this.db.upsertResource(resource)
86
+ return resource
87
+ }
88
+
89
+ withUser(overrides: Partial<UserConfig>): void {
90
+ this.userContext.setUser(overrides)
91
+ }
92
+ }
93
+
94
+ /** Reset the counter between tests */
95
+ export function resetConfigurerCounter(): void {
96
+ counter = 0
97
+ }
@@ -0,0 +1,66 @@
1
+ import type {
2
+ AppApprovalMethod,
3
+ AppCatalogData,
4
+ GroupingTagDefinition,
5
+ Resource,
6
+ } from '@igstack/app-catalog-backend-core'
7
+
8
+ export class MockDb {
9
+ resources: Resource[] = []
10
+ tagsDefinitions: GroupingTagDefinition[] = []
11
+ approvalMethods: AppApprovalMethod[] = []
12
+
13
+ upsertResource(resource: Resource): void {
14
+ this.resources = [
15
+ ...this.resources.filter((r) => r.id !== resource.id),
16
+ resource,
17
+ ]
18
+ }
19
+
20
+ /** @deprecated Use upsertResource */
21
+ upsertApp(app: Resource): void {
22
+ this.upsertResource(app)
23
+ }
24
+
25
+ getResources(): Resource[] {
26
+ return this.resources
27
+ }
28
+
29
+ /** @deprecated Use getResources */
30
+ getApps(): Resource[] {
31
+ return this.getResources()
32
+ }
33
+
34
+ getResource(slug: string): Resource {
35
+ const resource = this.resources.find((r) => r.slug === slug)
36
+ if (!resource) {
37
+ throw new Error(
38
+ `MockDb: resource with slug "${slug}" not found. Available: ${this.resources.map((r) => r.slug).join(', ')}`,
39
+ )
40
+ }
41
+ return resource
42
+ }
43
+
44
+ /** @deprecated Use getResource */
45
+ getApp(slug: string): Resource {
46
+ return this.getResource(slug)
47
+ }
48
+
49
+ setTagDefinitions(defs: GroupingTagDefinition[]): void {
50
+ this.tagsDefinitions = defs
51
+ }
52
+
53
+ setApprovalMethods(methods: AppApprovalMethod[]): void {
54
+ this.approvalMethods = methods
55
+ }
56
+
57
+ getAppCatalogData(): AppCatalogData {
58
+ return {
59
+ resources: this.resources,
60
+ tagsDefinitions: this.tagsDefinitions,
61
+ approvalMethods: this.approvalMethods,
62
+ persons: [],
63
+ groups: [],
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,18 @@
1
+ import type { AppCatalogData } from '@igstack/app-catalog-backend-core'
2
+ import type { MockDb } from './MockDb'
3
+ import type { MockUserContext } from './MockUserContext'
4
+
5
+ export class MockService {
6
+ constructor(
7
+ readonly db: MockDb,
8
+ readonly userContext: MockUserContext,
9
+ ) {}
10
+
11
+ getAppCatalogData(): AppCatalogData {
12
+ return this.db.getAppCatalogData()
13
+ }
14
+
15
+ getSessionResponse(): object | null {
16
+ return this.userContext.getSessionResponse()
17
+ }
18
+ }
@@ -0,0 +1,42 @@
1
+ export interface UserConfig {
2
+ name: string
3
+ isAdmin: boolean
4
+ isAuthenticated: boolean
5
+ }
6
+
7
+ const defaultUser: UserConfig = {
8
+ name: 'Test User',
9
+ isAdmin: false,
10
+ isAuthenticated: true,
11
+ }
12
+
13
+ export class MockUserContext {
14
+ private user: UserConfig = { ...defaultUser }
15
+
16
+ setUser(overrides: Partial<UserConfig>): void {
17
+ this.user = { ...this.user, ...overrides }
18
+ }
19
+
20
+ getUser(): UserConfig {
21
+ return this.user
22
+ }
23
+
24
+ getSessionResponse(): object | null {
25
+ if (!this.user.isAuthenticated) {
26
+ return null
27
+ }
28
+ return {
29
+ user: {
30
+ id: 'test-user-id',
31
+ email: `${this.user.name.toLowerCase().replace(/\s/g, '.')}@test.com`,
32
+ name: this.user.name,
33
+ isAdmin: this.user.isAdmin,
34
+ },
35
+ session: {
36
+ id: 'test-session-id',
37
+ userId: 'test-user-id',
38
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
39
+ },
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,245 @@
1
+ import type { MockBackendConfigurer } from './MockBackendConfigurer'
2
+ import type { BrowserStateCfg } from './BrowserStateCfg'
3
+ import type { NetworkConfigurerCfg } from '../mock-network/NetworkConfigurerCfg'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Core types
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface ConfigurerContext {
10
+ backendCfg: MockBackendConfigurer
11
+ browserStateCfg: BrowserStateCfg
12
+ networkCfg: NetworkConfigurerCfg
13
+ }
14
+
15
+ /** A magazine is a function that configures the context */
16
+ export type Magazine = (ctx: ConfigurerContext) => void
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Full magazine features
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface FullFeatures {
23
+ prepopulateCache?: boolean
24
+ dismissOnboarding?: boolean
25
+ }
26
+
27
+ function fullMagazine(
28
+ features: FullFeatures,
29
+ postConfigure?: Magazine,
30
+ ): Magazine {
31
+ return (ctx) => {
32
+ const { backendCfg, browserStateCfg } = ctx
33
+
34
+ // Approval methods — all 3 types
35
+ const supportPortal = backendCfg.withApprovalMethod({
36
+ slug: 'it-support',
37
+ type: 'service',
38
+ displayName: 'Support Portal',
39
+ config: {
40
+ url: 'https://support.example.com',
41
+ description: 'General IT support desk',
42
+ },
43
+ })
44
+ backendCfg.withApprovalMethod({
45
+ slug: 'ops-support',
46
+ type: 'service',
47
+ displayName: 'Ops Portal',
48
+ config: { url: 'https://opsdesk.example.com' },
49
+ })
50
+ const managerApproval = backendCfg.withApprovalMethod({
51
+ slug: 'manager-approval',
52
+ type: 'custom',
53
+ displayName: 'Manager Approval',
54
+ config: {},
55
+ })
56
+ backendCfg.withApprovalMethod({
57
+ slug: 'self-service',
58
+ type: 'custom',
59
+ displayName: 'Self-Service',
60
+ config: {},
61
+ })
62
+
63
+ // Tag definitions
64
+ backendCfg.withTag({
65
+ prefix: 'category',
66
+ displayName: 'Category',
67
+ description: 'Application category',
68
+ values: [
69
+ {
70
+ value: 'project-management',
71
+ displayName: 'Project Management',
72
+ description: '',
73
+ },
74
+ {
75
+ value: 'communication',
76
+ displayName: 'Communication',
77
+ description: '',
78
+ },
79
+ { value: 'internal', displayName: 'Internal Tools', description: '' },
80
+ ],
81
+ })
82
+ backendCfg.withTag({
83
+ prefix: 'team',
84
+ displayName: 'Team',
85
+ description: 'Owning team',
86
+ values: [
87
+ { value: 'engineering', displayName: 'Engineering', description: '' },
88
+ { value: 'platform', displayName: 'Platform', description: '' },
89
+ ],
90
+ })
91
+
92
+ // Apps
93
+ backendCfg.withApp({
94
+ slug: 'taskflow',
95
+ displayName: 'TaskFlow',
96
+ description: 'Project tracking and issue management',
97
+ tags: ['category:project-management', 'team:engineering'],
98
+ screenshotIds: ['ss-taskflow-1', 'ss-taskflow-2', 'ss-taskflow-3'],
99
+ appUrl: 'https://taskflow.example.com',
100
+ accessRequest: {
101
+ approvalMethodSlug: supportPortal.slug,
102
+ comments: 'Submit a ticket',
103
+ },
104
+ })
105
+
106
+ backendCfg.withApp({
107
+ slug: 'teamchat',
108
+ displayName: 'TeamChat',
109
+ description: 'Team messaging and collaboration',
110
+ tags: ['category:communication', 'team:platform'],
111
+ screenshotIds: ['ss-teamchat-1'],
112
+ appUrl: 'https://teamchat.example.com',
113
+ })
114
+
115
+ const newTool = backendCfg.withApp({
116
+ slug: 'new-tool',
117
+ displayName: 'New Tool',
118
+ description: 'The modern replacement',
119
+ tags: ['category:internal'],
120
+ })
121
+
122
+ backendCfg.withApp({
123
+ slug: 'old-tool',
124
+ displayName: 'Old Tool',
125
+ description: 'Legacy project tracker',
126
+ tags: ['category:project-management'],
127
+ deprecated: {
128
+ type: 'deprecated',
129
+ comment: 'Replaced by New Tool',
130
+ replacementSlug: newTool.slug,
131
+ },
132
+ })
133
+
134
+ backendCfg.withApp({
135
+ slug: 'legacy-app',
136
+ displayName: 'Legacy App',
137
+ description: 'Still works but discouraged',
138
+ tags: ['category:internal'],
139
+ deprecated: {
140
+ type: 'discouraged',
141
+ comment: 'Consider using alternatives',
142
+ },
143
+ })
144
+
145
+ backendCfg.withApp({
146
+ slug: 'internal-portal',
147
+ displayName: 'Internal Portal',
148
+ description: 'Company intranet portal',
149
+ tags: ['category:internal', 'team:platform'],
150
+ screenshotIds: ['ss-portal-1'],
151
+ accessRequest: {
152
+ approvalMethodSlug: managerApproval.slug,
153
+ comments: 'Requires manager approval',
154
+ },
155
+ })
156
+
157
+ backendCfg.withApp({
158
+ slug: 'admin-console',
159
+ displayName: 'Admin Console',
160
+ description: 'System administration dashboard',
161
+ tags: ['category:internal', 'team:engineering'],
162
+ })
163
+
164
+ // Apply features
165
+ if (features.dismissOnboarding) {
166
+ browserStateCfg.dismissOnboarding()
167
+ }
168
+ if (features.prepopulateCache) {
169
+ browserStateCfg.withOfflineData()
170
+ }
171
+
172
+ // Apply post-configurer (for test-specific overrides)
173
+ postConfigure?.(ctx)
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Single magazine
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function singleMagazine(postConfigure?: Magazine): Magazine {
182
+ return (ctx) => {
183
+ const { backendCfg } = ctx
184
+
185
+ const method = backendCfg.withApprovalMethod({
186
+ slug: 'it-support',
187
+ type: 'service',
188
+ displayName: 'Support Portal',
189
+ config: { url: 'https://support.example.com' },
190
+ })
191
+
192
+ backendCfg.withTag({
193
+ prefix: 'category',
194
+ displayName: 'Category',
195
+ description: 'Application category',
196
+ values: [
197
+ {
198
+ value: 'project-management',
199
+ displayName: 'Project Management',
200
+ description: '',
201
+ },
202
+ ],
203
+ })
204
+
205
+ backendCfg.withApp({
206
+ slug: 'taskflow',
207
+ displayName: 'TaskFlow',
208
+ description: 'Project tracking and issue management',
209
+ tags: ['category:project-management'],
210
+ screenshotIds: ['screenshot-taskflow-1', 'screenshot-taskflow-2'],
211
+ appUrl: 'https://taskflow.example.com',
212
+ accessRequest: {
213
+ approvalMethodSlug: method.slug,
214
+ comments: 'Submit a ticket to IT',
215
+ },
216
+ })
217
+
218
+ postConfigure?.(ctx)
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Magazine entries
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export const magazine = {
227
+ /** No apps, no tags, no approval methods */
228
+ empty: (() => {}) satisfies Magazine,
229
+
230
+ /** 1 app (TaskFlow), 1 tag, 1 approval method — accepts optional post-configurer */
231
+ single: (postConfigure?: Magazine) => singleMagazine(postConfigure),
232
+
233
+ /** Full catalog, first-time user — accepts optional post-configurer */
234
+ full: (postConfigure?: Magazine) => fullMagazine({}, postConfigure),
235
+
236
+ /** Full catalog, returning user (cache + onboarding dismissed) — accepts optional post-configurer */
237
+ fullReturningUser: (postConfigure?: Magazine) =>
238
+ fullMagazine(
239
+ { prepopulateCache: true, dismissOnboarding: true },
240
+ postConfigure,
241
+ ),
242
+
243
+ /** Inline custom configurer — pass-through for ad-hoc tests */
244
+ custom: (fn: Magazine): Magazine => fn,
245
+ }
@@ -0,0 +1,30 @@
1
+ import type { RequestHandler } from 'msw'
2
+
3
+ export interface NetworkInterceptor {
4
+ scopeKey: string[]
5
+ handler: RequestHandler
6
+ }
7
+
8
+ export class NetworkCatalog {
9
+ private interceptors: NetworkInterceptor[] = []
10
+
11
+ add(interceptor: NetworkInterceptor): void {
12
+ this.interceptors.push(interceptor)
13
+ }
14
+
15
+ replace(scopeKey: string[], handler: RequestHandler): void {
16
+ // Remove existing interceptor with matching key, add new one
17
+ this.interceptors = this.interceptors.filter(
18
+ (i) => !this.keysMatch(i.scopeKey, scopeKey),
19
+ )
20
+ this.interceptors.push({ scopeKey, handler })
21
+ }
22
+
23
+ getHandlers(): RequestHandler[] {
24
+ return this.interceptors.map((i) => i.handler)
25
+ }
26
+
27
+ private keysMatch(existing: string[], search: string[]): boolean {
28
+ return search.every((k) => existing.includes(k))
29
+ }
30
+ }
@@ -0,0 +1,24 @@
1
+ import type { NetworkCatalog } from './NetworkCatalog'
2
+ import type { MockService } from '../mock-backend/MockService'
3
+
4
+ export type NetworkOverrideFn = (
5
+ catalog: NetworkCatalog,
6
+ service: MockService,
7
+ ) => void
8
+
9
+ /**
10
+ * Data-carrier that collects network override callbacks.
11
+ * Does not execute anything — `given()` applies overrides after building the default catalog.
12
+ */
13
+ export class NetworkConfigurerCfg {
14
+ private _overrides: NetworkOverrideFn[] = []
15
+
16
+ /** Register a callback to modify the network catalog after default handlers are set up */
17
+ overrideConfig(fn: NetworkOverrideFn): void {
18
+ this._overrides.push(fn)
19
+ }
20
+
21
+ get overrides(): NetworkOverrideFn[] {
22
+ return this._overrides
23
+ }
24
+ }
@@ -0,0 +1,100 @@
1
+ import { HttpResponse, http } from 'msw'
2
+ import { createTRPCMsw, httpLink } from 'msw-trpc'
3
+ import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
4
+ import type { MockService } from '../mock-backend/MockService'
5
+ import type { NetworkInterceptor } from './NetworkCatalog'
6
+
7
+ const trpcMsw = createTRPCMsw<TRPCRouter>({
8
+ links: [httpLink({ url: '/api/trpc' })],
9
+ })
10
+
11
+ // 1x1 transparent PNG placeholder
12
+ const PLACEHOLDER_PNG = new Uint8Array([
13
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
14
+ 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
15
+ 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44,
16
+ 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe5, 0x27,
17
+ 0xde, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
18
+ 0x82,
19
+ ])
20
+
21
+ export const SharedNetwork = {
22
+ appCatalogQuery(service: MockService): NetworkInterceptor {
23
+ return {
24
+ scopeKey: ['appCatalog-query'],
25
+ handler: trpcMsw.appCatalog.getData.query(() => {
26
+ return service.getAppCatalogData()
27
+ }),
28
+ }
29
+ },
30
+
31
+ authGetSession(service: MockService): NetworkInterceptor {
32
+ return {
33
+ scopeKey: ['auth-session'],
34
+ handler: http.get(/\/api\/auth\/session/, () => {
35
+ const session = service.getSessionResponse()
36
+ if (!session) {
37
+ return HttpResponse.json(
38
+ { error: 'Not authenticated' },
39
+ { status: 401 },
40
+ )
41
+ }
42
+ return HttpResponse.json(session)
43
+ }),
44
+ }
45
+ },
46
+
47
+ authGetProviders(overrides?: {
48
+ devLoginEnabled?: boolean
49
+ }): NetworkInterceptor {
50
+ return {
51
+ scopeKey: ['auth-providers'],
52
+ handler: trpcMsw.auth.getProviders.query(() => {
53
+ return {
54
+ providers: [] as string[],
55
+ devLoginEnabled: overrides?.devLoginEnabled ?? false,
56
+ }
57
+ }),
58
+ }
59
+ },
60
+
61
+ authSignOut(): NetworkInterceptor {
62
+ return {
63
+ scopeKey: ['auth-sign-out'],
64
+ handler: http.post(/\/api\/auth\/sign-out/, () => {
65
+ return HttpResponse.json({ ok: true })
66
+ }),
67
+ }
68
+ },
69
+
70
+ authDevLogout(): NetworkInterceptor {
71
+ return {
72
+ scopeKey: ['auth-dev-logout'],
73
+ handler: http.post(/\/api\/auth\/dev-logout/, () => {
74
+ return HttpResponse.json({ ok: true })
75
+ }),
76
+ }
77
+ },
78
+
79
+ screenshotBinary(): NetworkInterceptor {
80
+ return {
81
+ scopeKey: ['screenshot'],
82
+ handler: http.get(/\/api\/screenshots\/.*/, () => {
83
+ return new HttpResponse(PLACEHOLDER_PNG, {
84
+ headers: { 'Content-Type': 'image/png' },
85
+ })
86
+ }),
87
+ }
88
+ },
89
+
90
+ iconBinary(): NetworkInterceptor {
91
+ return {
92
+ scopeKey: ['icon'],
93
+ handler: http.get(/\/api\/icons\/.*/, () => {
94
+ return new HttpResponse(PLACEHOLDER_PNG, {
95
+ headers: { 'Content-Type': 'image/png' },
96
+ })
97
+ }),
98
+ }
99
+ },
100
+ }
@@ -0,0 +1,28 @@
1
+ import { HttpResponse, http } from 'msw'
2
+ import type { RequestHandler } from 'msw'
3
+
4
+ /**
5
+ * Simulates a network-level connection reset error.
6
+ * This is what happens when the backend is completely unreachable.
7
+ */
8
+ export function makeConnectionResetError(): RequestHandler {
9
+ return http.get(/\/api\/trpc\/appCatalog\.getData/, () => {
10
+ return HttpResponse.error()
11
+ })
12
+ }
13
+
14
+ /**
15
+ * Simulates a reverse proxy returning HTML instead of JSON
16
+ * (e.g., nginx 502 Bad Gateway page).
17
+ */
18
+ export function makeHtmlResponseError(): RequestHandler {
19
+ return http.get(/\/api\/trpc\/appCatalog\.getData/, () => {
20
+ return new HttpResponse(
21
+ '<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1><p>The server is temporarily unable to service your request.</p></body></html>',
22
+ {
23
+ status: 502,
24
+ headers: { 'Content-Type': 'text/html' },
25
+ },
26
+ )
27
+ })
28
+ }
@@ -0,0 +1,19 @@
1
+ import type { MockService } from '../mock-backend/MockService'
2
+ import { NetworkCatalog } from './NetworkCatalog'
3
+ import { SharedNetwork } from './SharedNetwork'
4
+
5
+ export function makeNetworkReplyWithCatalog(
6
+ service: MockService,
7
+ ): NetworkCatalog {
8
+ const catalog = new NetworkCatalog()
9
+
10
+ catalog.add(SharedNetwork.appCatalogQuery(service))
11
+ catalog.add(SharedNetwork.authGetSession(service))
12
+ catalog.add(SharedNetwork.authGetProviders())
13
+ catalog.add(SharedNetwork.authSignOut())
14
+ catalog.add(SharedNetwork.authDevLogout())
15
+ catalog.add(SharedNetwork.screenshotBinary())
16
+ catalog.add(SharedNetwork.iconBinary())
17
+
18
+ return catalog
19
+ }