@ultimabase/testing 0.0.0-stage → 0.1.1

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 (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/aggregates.d.ts +3 -0
  4. package/dist/aggregates.d.ts.map +1 -0
  5. package/dist/aggregates.js +99 -0
  6. package/dist/as-user.d.ts +13 -0
  7. package/dist/as-user.d.ts.map +1 -0
  8. package/dist/as-user.js +17 -0
  9. package/dist/bulk-returns.d.ts +3 -0
  10. package/dist/bulk-returns.d.ts.map +1 -0
  11. package/dist/bulk-returns.js +125 -0
  12. package/dist/cascades.d.ts +3 -0
  13. package/dist/cascades.d.ts.map +1 -0
  14. package/dist/cascades.js +269 -0
  15. package/dist/cases.d.ts +3 -0
  16. package/dist/cases.d.ts.map +1 -0
  17. package/dist/cases.js +60 -0
  18. package/dist/counts.d.ts +3 -0
  19. package/dist/counts.d.ts.map +1 -0
  20. package/dist/counts.js +158 -0
  21. package/dist/days.d.ts +3 -0
  22. package/dist/days.d.ts.map +1 -0
  23. package/dist/days.js +74 -0
  24. package/dist/decimals.d.ts +3 -0
  25. package/dist/decimals.d.ts.map +1 -0
  26. package/dist/decimals.js +70 -0
  27. package/dist/distincts.d.ts +3 -0
  28. package/dist/distincts.d.ts.map +1 -0
  29. package/dist/distincts.js +80 -0
  30. package/dist/feeds.d.ts +3 -0
  31. package/dist/feeds.d.ts.map +1 -0
  32. package/dist/feeds.js +164 -0
  33. package/dist/groups.d.ts +3 -0
  34. package/dist/groups.d.ts.map +1 -0
  35. package/dist/groups.js +149 -0
  36. package/dist/index.d.ts +5 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +5 -0
  39. package/dist/json-paths.d.ts +3 -0
  40. package/dist/json-paths.d.ts.map +1 -0
  41. package/dist/json-paths.js +143 -0
  42. package/dist/lists.d.ts +3 -0
  43. package/dist/lists.d.ts.map +1 -0
  44. package/dist/lists.js +234 -0
  45. package/dist/nested.d.ts +3 -0
  46. package/dist/nested.d.ts.map +1 -0
  47. package/dist/nested.js +152 -0
  48. package/dist/numbers.d.ts +3 -0
  49. package/dist/numbers.d.ts.map +1 -0
  50. package/dist/numbers.js +92 -0
  51. package/dist/projection.d.ts +3 -0
  52. package/dist/projection.d.ts.map +1 -0
  53. package/dist/projection.js +53 -0
  54. package/dist/reads.d.ts +3 -0
  55. package/dist/reads.d.ts.map +1 -0
  56. package/dist/reads.js +312 -0
  57. package/dist/relation-filters.d.ts +3 -0
  58. package/dist/relation-filters.d.ts.map +1 -0
  59. package/dist/relation-filters.js +116 -0
  60. package/dist/round-trips.d.ts +3 -0
  61. package/dist/round-trips.d.ts.map +1 -0
  62. package/dist/round-trips.js +284 -0
  63. package/dist/searches.d.ts +3 -0
  64. package/dist/searches.d.ts.map +1 -0
  65. package/dist/searches.js +57 -0
  66. package/dist/silent.d.ts +3 -0
  67. package/dist/silent.d.ts.map +1 -0
  68. package/dist/silent.js +7 -0
  69. package/dist/sorts.d.ts +3 -0
  70. package/dist/sorts.d.ts.map +1 -0
  71. package/dist/sorts.js +108 -0
  72. package/dist/suite.d.ts +7 -0
  73. package/dist/suite.d.ts.map +1 -0
  74. package/dist/suite.js +15 -0
  75. package/dist/test-adapter.d.ts +11 -0
  76. package/dist/test-adapter.d.ts.map +1 -0
  77. package/dist/test-adapter.js +55 -0
  78. package/dist/test-application.d.ts +17 -0
  79. package/dist/test-application.d.ts.map +1 -0
  80. package/dist/test-application.js +68 -0
  81. package/dist/transactions.d.ts +3 -0
  82. package/dist/transactions.d.ts.map +1 -0
  83. package/dist/transactions.js +61 -0
  84. package/dist/virtuals.d.ts +3 -0
  85. package/dist/virtuals.d.ts.map +1 -0
  86. package/dist/virtuals.js +135 -0
  87. package/dist/writes.d.ts +3 -0
  88. package/dist/writes.d.ts.map +1 -0
  89. package/dist/writes.js +219 -0
  90. package/package.json +40 -3
  91. package/src/aggregates.ts +128 -0
  92. package/src/as-user.ts +26 -0
  93. package/src/bulk-returns.ts +151 -0
  94. package/src/cascades.ts +338 -0
  95. package/src/cases.ts +76 -0
  96. package/src/counts.ts +223 -0
  97. package/src/days.ts +107 -0
  98. package/src/decimals.ts +102 -0
  99. package/src/distincts.ts +107 -0
  100. package/src/feeds.ts +197 -0
  101. package/src/groups.ts +185 -0
  102. package/src/index.ts +6 -0
  103. package/src/json-paths.ts +185 -0
  104. package/src/lists.ts +304 -0
  105. package/src/nested.ts +187 -0
  106. package/src/numbers.ts +125 -0
  107. package/src/projection.ts +65 -0
  108. package/src/reads.ts +389 -0
  109. package/src/relation-filters.ts +151 -0
  110. package/src/round-trips.ts +392 -0
  111. package/src/searches.ts +82 -0
  112. package/src/silent.ts +11 -0
  113. package/src/sorts.ts +143 -0
  114. package/src/suite.ts +21 -0
  115. package/src/test-adapter.ts +68 -0
  116. package/src/test-application.ts +93 -0
  117. package/src/transactions.ts +76 -0
  118. package/src/virtuals.ts +184 -0
  119. package/src/writes.ts +315 -0
@@ -0,0 +1,68 @@
1
+ // The adapter contract suite (D-02, D-73, D-94): what every adapter does
2
+ // alike, checked through the kernel's public API.
3
+ import type { Adapter } from 'ultimabase'
4
+ import { describe } from 'vitest'
5
+ import { aggregates } from './aggregates.ts'
6
+ import { bulkReturns } from './bulk-returns.ts'
7
+ import { calendarDays } from './days.ts'
8
+ import { cases } from './cases.ts'
9
+ import { cascades } from './cascades.ts'
10
+ import { counts } from './counts.ts'
11
+ import { decimals } from './decimals.ts'
12
+ import { distincts } from './distincts.ts'
13
+ import { feeds } from './feeds.ts'
14
+ import { groups } from './groups.ts'
15
+ import { jsonPaths } from './json-paths.ts'
16
+ import { lists } from './lists.ts'
17
+ import { nested } from './nested.ts'
18
+ import { numbers } from './numbers.ts'
19
+ import { projection } from './projection.ts'
20
+ import { reads } from './reads.ts'
21
+ import { relationFilters } from './relation-filters.ts'
22
+ import { roundTrips } from './round-trips.ts'
23
+ import { searches } from './searches.ts'
24
+ import { sorts } from './sorts.ts'
25
+ import { transactions } from './transactions.ts'
26
+ import { virtuals } from './virtuals.ts'
27
+ import { writes } from './writes.ts'
28
+
29
+ export type Options = {
30
+ /** How many round trips the adapters have made so far, BEGIN and COMMIT included (D-98). */
31
+ readonly roundTrips: () => number
32
+ }
33
+
34
+ /**
35
+ * Runs the contract against the adapters `create` returns, one per test, each
36
+ * on an empty database.
37
+ */
38
+ export function testAdapter(
39
+ name: string,
40
+ create: () => Adapter,
41
+ options: Options,
42
+ ): void {
43
+ describe(`the ${name} adapter`, () => {
44
+ reads(create)
45
+ cases(create)
46
+ writes(create)
47
+ cascades(create)
48
+ lists(create)
49
+ jsonPaths(create)
50
+ nested(create)
51
+ numbers(create)
52
+ bulkReturns(create)
53
+ decimals(create)
54
+ calendarDays(create)
55
+ counts(create)
56
+ relationFilters(create)
57
+ virtuals(create)
58
+ aggregates(create)
59
+ groups(create)
60
+ distincts(create)
61
+ sorts(create)
62
+ searches(create)
63
+ projection(create)
64
+ transactions(create)
65
+ feeds(create)
66
+ roundTrips(create, options.roundTrips)
67
+ })
68
+ }
@@ -0,0 +1,93 @@
1
+ // An application's tests (D-130): before each test it starts as declared, on
2
+ // a new memory() or its declared database's test tables (D-137) and a free
3
+ // port, without info lines; after each it stops.
4
+ import type { Adapter } from 'ultimabase'
5
+ import { memory } from 'ultimabase'
6
+ import type { Lifecycle } from 'ultimabase/internal'
7
+ import { quietRuns, runningApi, scratchOf } from 'ultimabase/internal'
8
+ import { afterEach, beforeAll, beforeEach } from 'vitest'
9
+
10
+ export type TestApplication = {
11
+ /** The HTTP server's address during a test, such as `http://localhost:54321`. */
12
+ readonly url: string
13
+ }
14
+
15
+ /** Starts `run` with `PORT` set to `0`, then puts `PORT` back. */
16
+ async function onFreePort(run: Lifecycle): Promise<void> {
17
+ const port = process.env['PORT']
18
+ process.env['PORT'] = '0'
19
+ try {
20
+ await run.start()
21
+ } finally {
22
+ if (port === undefined) delete process.env['PORT']
23
+ else process.env['PORT'] = port
24
+ }
25
+ }
26
+
27
+ function urlOf(api: unknown): string | undefined {
28
+ if (typeof api !== 'object' || api === null || !('url' in api)) {
29
+ return undefined
30
+ }
31
+ const { url } = api
32
+ if (typeof url !== 'function') return undefined
33
+ const value: unknown = Reflect.apply(url, api, [])
34
+ return typeof value === 'string' ? value : undefined
35
+ }
36
+
37
+ export type TestOptions = {
38
+ /**
39
+ * `'declared'` runs the tests on the application's own database, in
40
+ * tables of the file's own, instead of a new `memory()`.
41
+ */
42
+ readonly database?: 'declared'
43
+ }
44
+
45
+ /** The declared database, which gives each test file empty tables. */
46
+ function declared(database: Adapter): () => Adapter {
47
+ // A database that starts empty every time, as memory(), needs nothing.
48
+ if (database.migrations === undefined) return () => database
49
+ const scratch = scratchOf(database)
50
+ if (scratch === undefined) {
51
+ throw new TypeError(
52
+ "testApplication runs on the declared database when it gives a test file tables of its own, as postgres() does; this one does not: remove { database: 'declared' }.",
53
+ )
54
+ }
55
+ beforeAll(() => scratch().drop)
56
+ return () => database
57
+ }
58
+
59
+ /**
60
+ * Runs `app`, the value `defineApp` returned, around each test of the file
61
+ * that calls it.
62
+ */
63
+ export function testApplication(
64
+ app: object,
65
+ options: TestOptions = {},
66
+ ): TestApplication {
67
+ const runs = quietRuns(app)
68
+ if (runs === undefined) {
69
+ throw new TypeError('testApplication takes the value defineApp returned.')
70
+ }
71
+ const database =
72
+ options.database === 'declared' ? declared(runs.declared) : memory
73
+ let run: Lifecycle | undefined
74
+ beforeEach(async () => {
75
+ run = runs.on(database())
76
+ await onFreePort(run)
77
+ })
78
+ afterEach(async () => {
79
+ await run?.stop()
80
+ run = undefined
81
+ })
82
+ return {
83
+ get url() {
84
+ const url = run === undefined ? undefined : urlOf(runningApi('http'))
85
+ if (url === undefined) {
86
+ throw new Error(
87
+ 'url is the address of httpPlugin(), read during a test: add httpPlugin() to the application.',
88
+ )
89
+ }
90
+ return url
91
+ },
92
+ }
93
+ }
@@ -0,0 +1,76 @@
1
+ // The transactions of the contract (D-73): one commit for every write, one
2
+ // rollback for all of them, and nothing seen outside before the commit.
3
+ import type { Adapter } from 'ultimabase'
4
+ import { defineModel, master, transaction } from 'ultimabase'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { started } from './suite.ts'
7
+
8
+ const M = { as: master }
9
+
10
+ const Account = defineModel({
11
+ name: 'Account',
12
+ fields: (f) => ({ name: f.string({ unique: true }) }),
13
+ })
14
+
15
+ const open = async (name: string) => Account.create({ data: { name } }, M)
16
+ const names = async (): Promise<string[]> =>
17
+ (await Account.findMany({ orderBy: { name: 'asc' } }, M)).map(
18
+ (account) => account.name,
19
+ )
20
+
21
+ export function transactions(create: () => Adapter): void {
22
+ describe('transactions', () => {
23
+ started(create, [Account])
24
+
25
+ it('commits every write together', async () => {
26
+ await transaction(async () => {
27
+ await open('bob')
28
+ await open('cy')
29
+ })
30
+ expect(await names()).toEqual(['bob', 'cy'])
31
+ })
32
+
33
+ it('reads its own writes', async () => {
34
+ const seen = await transaction(async () => {
35
+ await open('bob')
36
+ return names()
37
+ })
38
+ expect(seen).toEqual(['bob'])
39
+ })
40
+
41
+ it('rolls every write back when the callback throws', async () => {
42
+ await open('ann')
43
+ const failed = transaction(async () => {
44
+ await open('bob')
45
+ throw new Error('Stop.')
46
+ })
47
+ await expect(failed).rejects.toThrow('Stop.')
48
+ expect(await names()).toEqual(['ann'])
49
+ })
50
+
51
+ it('rolls back after a failed write, even one the callback catches', async () => {
52
+ const failed = transaction(async () => {
53
+ await open('bob')
54
+ await open('bob').catch(() => undefined)
55
+ })
56
+ await expect(failed).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' })
57
+ expect(await names()).toEqual([])
58
+ })
59
+
60
+ it('shows its writes to no one else before the commit', async () => {
61
+ const written = Promise.withResolvers<void>()
62
+ const resumed = Promise.withResolvers<void>()
63
+ const running = transaction(async () => {
64
+ await open('bob')
65
+ written.resolve()
66
+ await resumed.promise
67
+ })
68
+ await written.promise
69
+ // This read runs outside the transaction.
70
+ expect(await names()).toEqual([])
71
+ resumed.resolve()
72
+ await running
73
+ expect(await names()).toEqual(['bob'])
74
+ })
75
+ })
76
+ }
@@ -0,0 +1,184 @@
1
+ // Virtual fields in the contract (D-151): the adapter reads the fields a
2
+ // virtual needs though the caller selects none of them, and the rows a
3
+ // virtual returns are read again by id with their relations and counts.
4
+ import type { Adapter, Identity } from 'ultimabase'
5
+ import { allow, defineModel, master } from 'ultimabase'
6
+ import { beforeEach, describe, expect, it } from 'vitest'
7
+ import { started } from './suite.ts'
8
+
9
+ const M: { readonly as: Identity } = { as: master }
10
+
11
+ type Result<T> =
12
+ | { readonly value: T }
13
+ | { readonly issues: readonly { readonly message: string }[] }
14
+
15
+ /** A Standard Schema of what `check` accepts, as zod would type it. */
16
+ function schema<T>(check: (value: unknown) => value is T): {
17
+ readonly '~standard': {
18
+ readonly version: 1
19
+ readonly vendor: string
20
+ readonly validate: (value: unknown) => Result<T>
21
+ readonly types?: { readonly input: T; readonly output: T }
22
+ }
23
+ } {
24
+ const validate = (value: unknown): Result<T> =>
25
+ check(value) ? { value } : { issues: [{ message: 'Invalid.' }] }
26
+ return { '~standard': { version: 1, vendor: 'testing', validate } }
27
+ }
28
+
29
+ const text = schema((value): value is string => typeof value === 'string')
30
+ const width = schema((value): value is { readonly width: number } =>
31
+ Number.isInteger(Reflect.get(Object(value), 'width')),
32
+ )
33
+ const first = schema((value): value is { readonly first: number } =>
34
+ Number.isInteger(Reflect.get(Object(value), 'first')),
35
+ )
36
+
37
+ const Shelf = defineModel({
38
+ name: 'Shelf',
39
+ fields: (f) => ({
40
+ name: f.string(),
41
+ books: f.many(() => Book),
42
+ label: f.virtual(text, { needs: ['name'] }),
43
+ padded: f.virtual(text, { input: width, needs: ['name'] }),
44
+ latest: f.virtual(() => [Book], { input: first }),
45
+ }),
46
+ }).with({
47
+ access: allow.public,
48
+ virtuals: {
49
+ label: { run: ({ record }) => record.name.toUpperCase() },
50
+ padded: {
51
+ run: ({ input, record }) => record.name.padEnd(input.width, '.'),
52
+ },
53
+ latest: {
54
+ runMany: async ({ input, records }) => {
55
+ const ids = records.map((record) => record.id)
56
+ const books = await Book.findMany(
57
+ {
58
+ where: { shelf: { is: { id: { in: ids } } } },
59
+ orderBy: { title: 'desc' },
60
+ include: { shelf: { select: { id: true } } },
61
+ },
62
+ M,
63
+ )
64
+ return records.map((record) =>
65
+ books
66
+ .filter((book) => book.shelf?.id === record.id)
67
+ .slice(0, input.first),
68
+ )
69
+ },
70
+ },
71
+ },
72
+ })
73
+
74
+ const Book = defineModel({
75
+ name: 'Book',
76
+ fields: (f) => ({
77
+ title: f.string(),
78
+ shelf: f.one(() => Shelf, { optional: true }),
79
+ tags: f.many(() => Tag),
80
+ }),
81
+ }).with({ access: allow.public })
82
+
83
+ const Tag = defineModel({
84
+ name: 'Tag',
85
+ fields: (f) => ({ name: f.string(), books: f.many(() => Book) }),
86
+ }).with({ access: allow.public })
87
+
88
+ export function virtuals(create: () => Adapter): void {
89
+ describe('virtual fields', () => {
90
+ started(create, [Shelf, Book, Tag])
91
+
92
+ beforeEach(async () => {
93
+ await Shelf.create(
94
+ {
95
+ data: {
96
+ name: 'oak',
97
+ books: {
98
+ create: [
99
+ { title: 'A', tags: { create: [{ name: 'old' }] } },
100
+ { title: 'B' },
101
+ { title: 'C', tags: { create: [{ name: 'new' }] } },
102
+ ],
103
+ },
104
+ },
105
+ },
106
+ M,
107
+ )
108
+ await Shelf.create({ data: { name: 'elm' } }, M)
109
+ })
110
+
111
+ it('reads the fields it needs, which the row leaves out', async () => {
112
+ const rows = await Shelf.findMany(
113
+ {
114
+ orderBy: { name: 'asc' },
115
+ select: { label: true, padded: { width: 5 } },
116
+ },
117
+ M,
118
+ )
119
+ expect(rows).toStrictEqual([
120
+ { label: 'ELM', padded: 'elm..' },
121
+ { label: 'OAK', padded: 'oak..' },
122
+ ])
123
+ })
124
+
125
+ it('is in the default row without an input', async () => {
126
+ const row = await Shelf.findFirst({ where: { name: 'oak' } }, M)
127
+ expect(row?.label).toBe('OAK')
128
+ expect(row && 'padded' in row).toBe(false)
129
+ })
130
+
131
+ it('reads the rows it returns with their relations', async () => {
132
+ const rows = await Shelf.findMany(
133
+ {
134
+ orderBy: { name: 'asc' },
135
+ select: {
136
+ name: true,
137
+ latest: {
138
+ first: 2,
139
+ select: {
140
+ title: true,
141
+ shelf: { select: { label: true } },
142
+ tags: { select: { name: true } },
143
+ _count: { select: { tags: true } },
144
+ },
145
+ },
146
+ },
147
+ },
148
+ M,
149
+ )
150
+ expect(rows).toStrictEqual([
151
+ { name: 'elm', latest: [] },
152
+ {
153
+ name: 'oak',
154
+ latest: [
155
+ {
156
+ title: 'C',
157
+ shelf: { label: 'OAK' },
158
+ tags: [{ name: 'new' }],
159
+ _count: { tags: 1 },
160
+ },
161
+ {
162
+ title: 'B',
163
+ shelf: { label: 'OAK' },
164
+ tags: [],
165
+ _count: { tags: 0 },
166
+ },
167
+ ],
168
+ },
169
+ ])
170
+ })
171
+
172
+ it('is read under an included relation', async () => {
173
+ const book = await Book.findFirst(
174
+ {
175
+ where: { title: 'A' },
176
+ include: { shelf: { include: { padded: { width: 4 } } } },
177
+ },
178
+ M,
179
+ )
180
+ expect(book?.shelf?.label).toBe('OAK')
181
+ expect(book?.shelf?.padded).toBe('oak.')
182
+ })
183
+ })
184
+ }