@teleporthq/teleport-plugin-next-workflows 0.44.4 → 0.44.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 (29) hide show
  1. package/__tests__/account-delete-owned-rows.test.ts +488 -0
  2. package/__tests__/invoice-paid-status.test.ts +382 -0
  3. package/__tests__/invoice-tender.test.ts +5 -3
  4. package/dist/cjs/account-delete-route-generator.d.ts +9 -6
  5. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  6. package/dist/cjs/account-delete-route-generator.js +131 -1
  7. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  8. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  9. package/dist/cjs/invoice/invoice-assembly-code.js +19 -2
  10. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  11. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  12. package/dist/cjs/invoice/invoice-html-code.js +100 -71
  13. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  14. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  15. package/dist/esm/account-delete-route-generator.d.ts +9 -6
  16. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  17. package/dist/esm/account-delete-route-generator.js +131 -1
  18. package/dist/esm/account-delete-route-generator.js.map +1 -1
  19. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  20. package/dist/esm/invoice/invoice-assembly-code.js +19 -2
  21. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  22. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  23. package/dist/esm/invoice/invoice-html-code.js +100 -71
  24. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  25. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/account-delete-route-generator.ts +140 -7
  28. package/src/invoice/invoice-assembly-code.ts +19 -2
  29. package/src/invoice/invoice-html-code.ts +100 -71
@@ -0,0 +1,488 @@
1
+ import { OwnerColumns } from '@teleporthq/teleport-shared'
2
+ import { generateAccountDeleteRoute } from '../src/account-delete-route-generator'
3
+
4
+ /**
5
+ * A visitor closes their account. The generated delete route is EXECUTED here
6
+ * against a stubbed `pg` client that answers the catalogue reads from a fixture
7
+ * schema and records every statement, so the proof is behaviour: which rows the
8
+ * route deletes, anonymises and leaves alone.
9
+ *
10
+ * THE DEFECT. The route deleted a fixed list of platform tables and the user
11
+ * row, and nothing in the project's own tables — saved items, requests,
12
+ * reviews, the tables the AI made for the site. Their owner link is
13
+ * `ON DELETE SET NULL`, so the rows stayed with a blank owner, and the "My …"
14
+ * pages read `owner = me OR owner IS NULL`: every other signed-in visitor saw
15
+ * them. `teleport_bookings` has no link to users at all and kept the user id
16
+ * with the customer's name, email and phone.
17
+ *
18
+ * THE RULE. A table's rows are attributed through exactly ONE column — the one
19
+ * its "My …" pages read by (teleport-shared's `resolveOwnerColumn`, shipped
20
+ * into the route as text) — and deleted only when that column says whose the
21
+ * row is, never when it says who acted on it.
22
+ */
23
+
24
+ const USER_ID = '7d1c4c6a-2f7e-4d3b-9c1e-1a2b3c4d5e6f'
25
+ const USER_EMAIL = 'ana@example.com'
26
+ const SYNTHETIC_EMAIL = `deleted-user-${USER_ID}@deleted.invalid`
27
+
28
+ interface FixtureTable {
29
+ columns: string[]
30
+ /** column -> `table.column` it references through a real foreign key. */
31
+ links?: Record<string, string>
32
+ }
33
+ type FixtureSchema = Record<string, FixtureTable>
34
+
35
+ interface Statement {
36
+ sql: string
37
+ params: unknown[]
38
+ }
39
+
40
+ interface Run {
41
+ status: number
42
+ body: unknown
43
+ statements: Statement[]
44
+ errors: unknown[][]
45
+ }
46
+
47
+ /** `attempt` counts the executions of that exact statement, from 1. */
48
+ type Refuse = (sql: string, attempt: number) => Error | null
49
+
50
+ /** The platform tables a store carries; how the route treats them is the baseline. */
51
+ const PLATFORM: FixtureSchema = {
52
+ users: { columns: ['id', 'email', 'name', 'password'] },
53
+ password_reset_tokens: { columns: ['id', 'email', 'token'] },
54
+ teleport_orders: { columns: ['id', 'user_id', 'billing_email', 'billing_name', 'total'] },
55
+ teleport_cart: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
56
+ teleport_subscriptions: {
57
+ columns: ['id', 'user_id', 'customer_email', 'customer_name', 'status'],
58
+ },
59
+ teleport_abandoned_carts: { columns: ['id', 'cart_id', 'user_id', 'customer_email'] },
60
+ teleport_voucher_redemptions: {
61
+ columns: ['id', 'voucher_id', 'order_id', 'user_id', 'billing_email'],
62
+ },
63
+ teleport_push_subscriptions: { columns: ['id', 'endpoint', 'user_id'] },
64
+ }
65
+
66
+ const USER_ROW = 'SELECT * FROM users WHERE id = $1'
67
+ const COLUMNS_OF_ONE_TABLE =
68
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1"
69
+ const COLUMNS_OF_EVERY_TABLE = 'SELECT c.table_name, c.column_name'
70
+ const FOREIGN_KEYS = 'FROM pg_constraint'
71
+
72
+ const columnRows = (schema: FixtureSchema) => {
73
+ const rows: Array<{ table_name: string; column_name: string; ordinal_position: number }> = []
74
+ for (const tableName of Object.keys(schema)) {
75
+ schema[tableName].columns.forEach((columnName, index) => {
76
+ rows.push({ table_name: tableName, column_name: columnName, ordinal_position: index + 1 })
77
+ })
78
+ }
79
+ return rows
80
+ }
81
+
82
+ const linkRows = (schema: FixtureSchema) => {
83
+ const rows: Array<{
84
+ table_name: string
85
+ column_name: string
86
+ referenced_table: string
87
+ referenced_column: string
88
+ }> = []
89
+ for (const tableName of Object.keys(schema)) {
90
+ const links = schema[tableName].links || {}
91
+ for (const columnName of Object.keys(links)) {
92
+ const [referencedTable, referencedColumn] = links[columnName].split('.')
93
+ rows.push({
94
+ table_name: tableName,
95
+ column_name: columnName,
96
+ referenced_table: referencedTable,
97
+ referenced_column: referencedColumn,
98
+ })
99
+ }
100
+ }
101
+ return rows
102
+ }
103
+
104
+ const runRoute = async (schema: FixtureSchema, refuse: Refuse = () => null): Promise<Run> => {
105
+ const run: Run = { status: 0, body: undefined, statements: [], errors: [] }
106
+ const attempts: Record<string, number> = {}
107
+
108
+ class Client {
109
+ public async connect() {
110
+ return
111
+ }
112
+ public async query(sql: string, params: unknown[] = []) {
113
+ run.statements.push({ sql, params })
114
+ if (sql === USER_ROW) {
115
+ return { rows: [{ id: USER_ID, email: USER_EMAIL, name: 'Ana' }] }
116
+ }
117
+ if (sql === COLUMNS_OF_ONE_TABLE) {
118
+ const table = schema[String(params[0])]
119
+ return { rows: table ? table.columns.map((column_name) => ({ column_name })) : [] }
120
+ }
121
+ if (sql.indexOf(COLUMNS_OF_EVERY_TABLE) === 0) {
122
+ return { rows: columnRows(schema) }
123
+ }
124
+ if (sql.indexOf(FOREIGN_KEYS) !== -1) {
125
+ return { rows: linkRows(schema) }
126
+ }
127
+ // A catalogue read this stub does not know would otherwise answer no rows
128
+ // and let every "left alone" scenario pass with nothing looked at.
129
+ if (sql.indexOf('SELECT') === 0) {
130
+ throw new Error(`unexpected catalogue read: ${sql}`)
131
+ }
132
+ attempts[sql] = (attempts[sql] || 0) + 1
133
+ const refusal = refuse(sql, attempts[sql])
134
+ if (refusal) {
135
+ throw refusal
136
+ }
137
+ return { rows: [] }
138
+ }
139
+ public async end() {
140
+ return
141
+ }
142
+ }
143
+
144
+ const fakeRequire = (name: string): any => {
145
+ if (name === 'pg') {
146
+ return { Client }
147
+ }
148
+ if (name === 'next-auth/jwt') {
149
+ return { getToken: async () => ({ id: USER_ID }) }
150
+ }
151
+ if (name.endsWith('/utils/email/email-locale')) {
152
+ return { pickLocalizedTemplate: (base: unknown) => base, resolveRequestLocale: () => 'en' }
153
+ }
154
+ throw new Error(`unexpected require: ${name}`)
155
+ }
156
+ const moduleObj: { exports: any } = { exports: {} }
157
+ // tslint:disable-next-line:function-constructor
158
+ new Function(
159
+ 'require',
160
+ 'module',
161
+ 'exports',
162
+ 'process',
163
+ 'console',
164
+ generateAccountDeleteRoute({ authUsersTableName: 'users' })
165
+ )(
166
+ fakeRequire,
167
+ moduleObj,
168
+ moduleObj.exports,
169
+ {
170
+ env: {
171
+ TELEPORT_DB_CONNECTION_STRING: 'postgresql://user:pw@db.test/site',
172
+ NEXTAUTH_SECRET: 'secret',
173
+ },
174
+ },
175
+ { error: (...args: unknown[]) => run.errors.push(args), warn: () => undefined }
176
+ )
177
+ const handler = moduleObj.exports as (req: unknown, res: unknown) => Promise<unknown>
178
+ const res = {
179
+ status(code: number) {
180
+ run.status = code
181
+ return res
182
+ },
183
+ json(body: unknown) {
184
+ run.body = body
185
+ return res
186
+ },
187
+ }
188
+ await handler({ method: 'POST', headers: {} }, res)
189
+ return run
190
+ }
191
+
192
+ const sqlOf = (run: Run): string[] => run.statements.map((statement) => statement.sql)
193
+
194
+ /** The statements that change something, in order — the catalogue reads left out. */
195
+ const writesOf = (run: Run): string[] =>
196
+ sqlOf(run).filter((sql) => /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|DELETE|UPDATE)/.test(sql))
197
+
198
+ /** The deletes in the site's own tables: the route quotes those identifiers. */
199
+ const ownDeletesOf = (run: Run): Statement[] =>
200
+ run.statements.filter((statement) => statement.sql.indexOf('DELETE FROM "') === 0)
201
+
202
+ interface Scenario {
203
+ table: string
204
+ columns: string[]
205
+ links?: Record<string, string>
206
+ /** The column the visitor's rows go through, or null when the table is left alone. */
207
+ deletedThrough: string | null
208
+ why: string
209
+ }
210
+
211
+ const SCENARIOS: Scenario[] = [
212
+ {
213
+ table: 'saved_items',
214
+ columns: ['id', 'user_id', 'title'],
215
+ links: { user_id: 'users.id' },
216
+ deletedThrough: 'user_id',
217
+ why: 'the plain owner link',
218
+ },
219
+ {
220
+ table: 'requests',
221
+ columns: ['id', 'owner_user_id', 'subject'],
222
+ links: { owner_user_id: 'users.id' },
223
+ deletedThrough: 'owner_user_id',
224
+ why: 'the <role>_user_id convention, an owning role',
225
+ },
226
+ {
227
+ table: 'reviews',
228
+ columns: ['id', 'created_by', 'body'],
229
+ deletedThrough: 'created_by',
230
+ why: 'an older project whose link to users was dropped: the name is all there is',
231
+ },
232
+ {
233
+ table: 'posts',
234
+ columns: ['id', 'author_id', 'body'],
235
+ links: { author_id: 'users.id' },
236
+ deletedThrough: 'author_id',
237
+ why: 'the pipeline names the signed-in author this way; the page is scoped by it',
238
+ },
239
+ {
240
+ table: 'expenses',
241
+ columns: ['id', 'submitter_user_id', 'approved_by_user_id', 'amount'],
242
+ links: { submitter_user_id: 'users.id', approved_by_user_id: 'users.id' },
243
+ deletedThrough: 'submitter_user_id',
244
+ why: 'an approver leaving must not delete the expenses they approved',
245
+ },
246
+ {
247
+ table: 'tickets',
248
+ columns: ['id', 'user_id', 'created_by', 'subject'],
249
+ links: { user_id: 'users.id', created_by: 'users.id' },
250
+ deletedThrough: 'user_id',
251
+ why: "an agent leaving must not delete the customers' tickets they opened",
252
+ },
253
+ {
254
+ table: 'listing_inquiries',
255
+ columns: ['id', 'sender_user_id', 'recipient_user_id', 'listing_id', 'body'],
256
+ links: { sender_user_id: 'users.id', recipient_user_id: 'users.id' },
257
+ deletedThrough: 'sender_user_id',
258
+ why: "a seller leaving must not delete the buyers' inquiries",
259
+ },
260
+ {
261
+ table: 'wishlists',
262
+ columns: ['id', 'owner_user_id', 'user_id'],
263
+ links: { owner_user_id: 'users.id', user_id: 'users.id' },
264
+ deletedThrough: 'user_id',
265
+ why: 'one column per table — the one the page reads by, whatever the column order',
266
+ },
267
+ {
268
+ table: 'accounts',
269
+ columns: ['id', 'user_id', 'label'],
270
+ links: { user_id: 'users.id' },
271
+ deletedThrough: 'user_id',
272
+ why: "a table named like a platform scope is still the site's own",
273
+ },
274
+ {
275
+ table: 'tasks',
276
+ columns: ['id', 'title', 'assigned_to', 'updated_by'],
277
+ links: { assigned_to: 'users.id', updated_by: 'users.id' },
278
+ deletedThrough: null,
279
+ why: "actor links only: the rows are other people's",
280
+ },
281
+ {
282
+ table: 'approvals',
283
+ columns: ['id', 'approved_by_user_id', 'note'],
284
+ links: { approved_by_user_id: 'users.id' },
285
+ deletedThrough: null,
286
+ why: "the page reads by an acting role; what leaks there is the read side's null-inclusive predicate",
287
+ },
288
+ {
289
+ table: 'transactions',
290
+ columns: ['id', 'account_id', 'amount'],
291
+ links: { account_id: 'accounts.id' },
292
+ deletedThrough: null,
293
+ why: 'account_id names an account of the site, not a user: a domain link',
294
+ },
295
+ {
296
+ table: 'products',
297
+ columns: ['id', 'name', 'price'],
298
+ deletedThrough: null,
299
+ why: 'nobody owns the row',
300
+ },
301
+ {
302
+ table: 'status_changes',
303
+ columns: ['id', 'ticket_id', 'from_status', 'to_status', 'changed_by_user_id'],
304
+ links: { changed_by_user_id: 'users.id' },
305
+ deletedThrough: null,
306
+ why: "the leaver's history rows on everyone's tickets are not theirs: changing is not creating",
307
+ },
308
+ ]
309
+
310
+ describe("closing an account — the rows the visitor owns in the site's own tables", () => {
311
+ describe.each(SCENARIOS.map((scenario) => [scenario.table, scenario] as [string, Scenario]))(
312
+ '%s',
313
+ (_table, scenario) => {
314
+ it(scenario.why, async () => {
315
+ const run = await runRoute({
316
+ ...PLATFORM,
317
+ [scenario.table]: { columns: scenario.columns, links: scenario.links },
318
+ })
319
+
320
+ expect(run.status).toBe(200)
321
+ expect(run.body).toEqual({ success: true })
322
+ expect(ownDeletesOf(run)).toEqual(
323
+ scenario.deletedThrough === null
324
+ ? []
325
+ : [
326
+ {
327
+ sql: `DELETE FROM "${scenario.table}" WHERE "${scenario.deletedThrough}" = $1`,
328
+ params: [USER_ID],
329
+ },
330
+ ]
331
+ )
332
+ expect(run.errors).toEqual([])
333
+ })
334
+ }
335
+ )
336
+
337
+ it('deletes them under their own savepoints, inside the one transaction, before the account', async () => {
338
+ const run = await runRoute({
339
+ ...PLATFORM,
340
+ saved_items: { columns: ['id', 'user_id', 'title'], links: { user_id: 'users.id' } },
341
+ posts: { columns: ['id', 'author_id', 'body'], links: { author_id: 'users.id' } },
342
+ })
343
+
344
+ const writes = writesOf(run)
345
+ expect(writes[0]).toBe('BEGIN')
346
+ expect(writes[writes.length - 1]).toBe('COMMIT')
347
+ const at = writes.indexOf('DELETE FROM "posts" WHERE "author_id" = $1')
348
+ expect(writes.slice(at - 1, at + 2)).toEqual([
349
+ 'SAVEPOINT owned_rows',
350
+ 'DELETE FROM "posts" WHERE "author_id" = $1',
351
+ 'RELEASE SAVEPOINT owned_rows',
352
+ ])
353
+ expect(writes.indexOf('DELETE FROM "saved_items" WHERE "user_id" = $1')).toBeLessThan(
354
+ writes.indexOf('DELETE FROM users WHERE id = $1')
355
+ )
356
+ expect(at).toBeLessThan(writes.indexOf('DELETE FROM users WHERE id = $1'))
357
+ })
358
+
359
+ it('anonymises a booking like an order: no account, no name, no phone, a synthetic email', async () => {
360
+ const run = await runRoute({
361
+ ...PLATFORM,
362
+ teleport_bookings: {
363
+ columns: [
364
+ 'id',
365
+ 'reference',
366
+ 'user_id',
367
+ 'customer_name',
368
+ 'customer_email',
369
+ 'customer_phone',
370
+ 'billing_name',
371
+ 'notes',
372
+ 'starts_at',
373
+ ],
374
+ },
375
+ })
376
+
377
+ expect(run.status).toBe(200)
378
+ const booking = run.statements.filter((statement) =>
379
+ statement.sql.startsWith('UPDATE teleport_bookings')
380
+ )
381
+ expect(booking).toEqual([
382
+ {
383
+ sql: 'UPDATE teleport_bookings SET user_id = NULL, customer_email = $2, customer_name = NULL, customer_phone = NULL WHERE user_id = $1',
384
+ params: [USER_ID, SYNTHETIC_EMAIL],
385
+ },
386
+ ])
387
+ const writes = writesOf(run)
388
+ expect(writes.indexOf(booking[0].sql)).toBeLessThan(
389
+ writes.indexOf('DELETE FROM users WHERE id = $1')
390
+ )
391
+ expect(sqlOf(run).join('\n')).not.toContain('DELETE FROM teleport_bookings')
392
+ expect(sqlOf(run).join('\n')).not.toContain('"teleport_bookings"')
393
+ })
394
+
395
+ it('a project with no tables of its own touches only the platform tables, in their order', async () => {
396
+ const run = await runRoute(PLATFORM)
397
+
398
+ expect(run.status).toBe(200)
399
+ expect(writesOf(run)).toEqual([
400
+ 'BEGIN',
401
+ 'UPDATE teleport_orders SET user_id = NULL, billing_email = $2 WHERE user_id = $1',
402
+ 'UPDATE teleport_subscriptions SET user_id = NULL, customer_email = $2 WHERE user_id = $1',
403
+ 'UPDATE teleport_abandoned_carts SET user_id = NULL, customer_email = $2 WHERE user_id = $1',
404
+ 'UPDATE teleport_voucher_redemptions SET user_id = NULL, billing_email = $2 WHERE user_id = $1',
405
+ 'DELETE FROM teleport_cart WHERE user_id = $1',
406
+ 'DELETE FROM teleport_push_subscriptions WHERE user_id = $1',
407
+ 'DELETE FROM password_reset_tokens WHERE email = $1',
408
+ 'DELETE FROM users WHERE id = $1',
409
+ 'COMMIT',
410
+ ])
411
+ for (const update of run.statements.filter((statement) => statement.sql.startsWith('UPDATE'))) {
412
+ expect(update.params).toEqual([USER_ID, SYNTHETIC_EMAIL])
413
+ }
414
+ expect(run.errors).toEqual([])
415
+ })
416
+
417
+ it('a table that refuses stops neither the other tables nor the account; its rows are retried, then named', async () => {
418
+ const stepsStillPointAtRecipes = new Error(
419
+ 'update or delete on table "recipes" violates foreign key constraint "recipe_steps_recipe_id_fkey" on table "recipe_steps"'
420
+ )
421
+ const someoneElsesCommentPointsAtPost = new Error(
422
+ 'update or delete on table "posts" violates foreign key constraint "comments_post_id_fkey" on table "comments"'
423
+ )
424
+ const run = await runRoute(
425
+ {
426
+ ...PLATFORM,
427
+ recipes: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
428
+ recipe_steps: {
429
+ columns: ['id', 'recipe_id', 'user_id'],
430
+ links: { recipe_id: 'recipes.id', user_id: 'users.id' },
431
+ },
432
+ posts: { columns: ['id', 'user_id'], links: { user_id: 'users.id' } },
433
+ },
434
+ (sql, attempt) => {
435
+ if (sql === 'DELETE FROM "recipes" WHERE "user_id" = $1' && attempt === 1) {
436
+ return stepsStillPointAtRecipes
437
+ }
438
+ if (sql === 'DELETE FROM "posts" WHERE "user_id" = $1') {
439
+ return someoneElsesCommentPointsAtPost
440
+ }
441
+ return null
442
+ }
443
+ )
444
+
445
+ expect(run.status).toBe(200)
446
+ const writes = writesOf(run)
447
+ expect(writes[writes.length - 1]).toBe('COMMIT')
448
+ expect(writes).toContain('DELETE FROM users WHERE id = $1')
449
+ expect(
450
+ writes.filter((sql) => sql === 'DELETE FROM "recipes" WHERE "user_id" = $1')
451
+ ).toHaveLength(2)
452
+ expect(
453
+ writes.filter((sql) => sql === 'DELETE FROM "recipe_steps" WHERE "user_id" = $1')
454
+ ).toHaveLength(1)
455
+ // Refused in the first round, in the round that freed the recipes, and once more alone.
456
+ expect(writes.filter((sql) => sql === 'DELETE FROM "posts" WHERE "user_id" = $1')).toHaveLength(
457
+ 3
458
+ )
459
+ const rollbacks = writes
460
+ .map((sql, index) => (sql === 'ROLLBACK TO SAVEPOINT owned_rows' ? index : -1))
461
+ .filter((index) => index !== -1)
462
+ expect(rollbacks).toHaveLength(4)
463
+ for (const index of rollbacks) {
464
+ expect(writes[index + 1]).toBe('RELEASE SAVEPOINT owned_rows')
465
+ }
466
+ expect(writes).not.toContain('ROLLBACK')
467
+ expect(run.errors).toEqual([
468
+ [`[account-delete] rows kept in posts (user_id): ${someoneElsesCommentPointsAtPost.message}`],
469
+ ])
470
+ })
471
+
472
+ it('reads the catalogue with the column positions, in table and column order', async () => {
473
+ const run = await runRoute(PLATFORM)
474
+ const catalogue = sqlOf(run).filter((sql) => sql.indexOf(COLUMNS_OF_EVERY_TABLE) === 0)
475
+ expect(catalogue).toHaveLength(1)
476
+ expect(catalogue[0]).toContain('SELECT c.table_name, c.column_name, c.ordinal_position FROM')
477
+ expect(catalogue[0]).toContain(" AND t.table_type = 'BASE TABLE'")
478
+ expect(catalogue[0].endsWith(' ORDER BY c.table_name, c.ordinal_position')).toBe(true)
479
+ })
480
+
481
+ it('carries the shared resolver as text, byte for byte', () => {
482
+ const code = generateAccountDeleteRoute({ authUsersTableName: 'users' })
483
+ expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.resolveOwnerColumn)
484
+ expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.attributionKind)
485
+ expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.userIdRole)
486
+ expect(code).toContain(OwnerColumns.OWNER_COLUMN_SOURCES.ownerColumnPriority)
487
+ })
488
+ })