@ultimabase/testing 0.1.2 → 0.3.0-canary.20261031104207
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.
- package/CHANGELOG.md +23 -0
- package/dist/test-adapter.d.ts.map +1 -1
- package/dist/test-adapter.js +2 -0
- package/dist/test-application.d.ts +5 -0
- package/dist/test-application.d.ts.map +1 -1
- package/dist/test-application.js +8 -1
- package/dist/unique-cases.d.ts +3 -0
- package/dist/unique-cases.d.ts.map +1 -0
- package/dist/unique-cases.js +68 -0
- package/package.json +4 -4
- package/src/test-adapter.ts +2 -0
- package/src/test-application.ts +18 -1
- package/src/unique-cases.ts +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @ultimabase/testing
|
|
2
2
|
|
|
3
|
+
## 0.3.0-canary.20261031104207
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6e8ebf6: `testApplication` returns `feed.missed()`, which calls each plugin's `missed` during a test (D-168).
|
|
8
|
+
- 353c2bf: A string field takes `unique: 'insensitive'`, and a unique index `mode: 'insensitive'`: `Alice` then clashes with `alice`, while each row keeps its case, and the `UNIQUE_VIOLATION` names the field (D-170). `postgres()` makes a unique index over `lower()`, which also serves an insensitive `equals`: run a migration after the upgrade to add it. `@ultimabase/testing` checks an adapter's case-insensitive keys.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- Updated dependencies [41cfff6]
|
|
13
|
+
- Updated dependencies [6e8ebf6]
|
|
14
|
+
- Updated dependencies [9cd2dd4]
|
|
15
|
+
- Updated dependencies [353c2bf]
|
|
16
|
+
- ultimabase@0.3.0-canary.20261031104207
|
|
17
|
+
|
|
18
|
+
## 0.2.0
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [2c3bf4e]
|
|
23
|
+
- Updated dependencies [fd1263e]
|
|
24
|
+
- ultimabase@0.2.0
|
|
25
|
+
|
|
3
26
|
## 0.1.2
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-adapter.d.ts","sourceRoot":"","sources":["../src/test-adapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"test-adapter.d.ts","sourceRoot":"","sources":["../src/test-adapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AA2BzC,MAAM,MAAM,OAAO,GAAG;IACrB,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,EAAE,MAAM,MAAM,CAAA;CACjC,CAAA;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,OAAO,EACrB,OAAO,EAAE,OAAO,GACd,IAAI,CA2BN"}
|
package/dist/test-adapter.js
CHANGED
|
@@ -20,6 +20,7 @@ import { roundTrips } from './round-trips.js';
|
|
|
20
20
|
import { searches } from './searches.js';
|
|
21
21
|
import { sorts } from './sorts.js';
|
|
22
22
|
import { transactions } from './transactions.js';
|
|
23
|
+
import { uniqueCases } from './unique-cases.js';
|
|
23
24
|
import { virtuals } from './virtuals.js';
|
|
24
25
|
import { writes } from './writes.js';
|
|
25
26
|
/**
|
|
@@ -31,6 +32,7 @@ export function testAdapter(name, create, options) {
|
|
|
31
32
|
reads(create);
|
|
32
33
|
cases(create);
|
|
33
34
|
writes(create);
|
|
35
|
+
uniqueCases(create);
|
|
34
36
|
cascades(create);
|
|
35
37
|
lists(create);
|
|
36
38
|
jsonPaths(create);
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export type TestApplication = {
|
|
2
2
|
/** The HTTP server's address during a test, such as `http://localhost:54321`. */
|
|
3
3
|
readonly url: string;
|
|
4
|
+
/** The change feed, during a test (D-168). */
|
|
5
|
+
readonly feed: {
|
|
6
|
+
/** Calls each plugin's `missed`, as after a lost connection. */
|
|
7
|
+
readonly missed: () => void;
|
|
8
|
+
};
|
|
4
9
|
};
|
|
5
10
|
export type TestOptions = {
|
|
6
11
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-application.d.ts","sourceRoot":"","sources":["../src/test-application.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"test-application.d.ts","sourceRoot":"","sources":["../src/test-application.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,eAAe,GAAG;IAC7B,iFAAiF;IACjF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAE;QACd,gEAAgE;QAChE,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAA;KAC3B,CAAA;CACD,CAAA;AAwBD,MAAM,MAAM,WAAW,GAAG;IACzB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAA;CAC9B,CAAA;AAgBD;;;GAGG;AACH,wBAAgB,eAAe,CAC9B,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,WAAgB,GACvB,eAAe,CAkCjB"}
|
package/dist/test-application.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memory } from 'ultimabase';
|
|
2
|
-
import { quietRuns, runningApi, scratchOf } from 'ultimabase/internal';
|
|
2
|
+
import { quietRuns, runningApi, runningFeed, scratchOf, } from 'ultimabase/internal';
|
|
3
3
|
import { afterEach, beforeAll, beforeEach } from 'vitest';
|
|
4
4
|
/** Starts `run` with `PORT` set to `0`, then puts `PORT` back. */
|
|
5
5
|
async function onFreePort(run) {
|
|
@@ -64,5 +64,12 @@ export function testApplication(app, options = {}) {
|
|
|
64
64
|
}
|
|
65
65
|
return url;
|
|
66
66
|
},
|
|
67
|
+
feed: {
|
|
68
|
+
missed: () => {
|
|
69
|
+
if (run === undefined)
|
|
70
|
+
throw new Error('feed.missed() runs during a test.');
|
|
71
|
+
runningFeed()?.missed();
|
|
72
|
+
},
|
|
73
|
+
},
|
|
67
74
|
};
|
|
68
75
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unique-cases.d.ts","sourceRoot":"","sources":["../src/unique-cases.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AA2BzC,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAwFvD"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineModel, master } from 'ultimabase';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { started } from './suite.js';
|
|
4
|
+
const M = { as: master };
|
|
5
|
+
const club = (id) => ({ connect: { id } });
|
|
6
|
+
const Account = defineModel({
|
|
7
|
+
name: 'Account',
|
|
8
|
+
fields: (f) => ({
|
|
9
|
+
username: f.string({ unique: 'insensitive' }),
|
|
10
|
+
nickname: f.string({ unique: 'insensitive', optional: true }),
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
const Club = defineModel({
|
|
14
|
+
name: 'Club',
|
|
15
|
+
fields: (f) => ({ name: f.string(), members: f.many(() => Member) }),
|
|
16
|
+
});
|
|
17
|
+
const Member = defineModel({
|
|
18
|
+
name: 'Member',
|
|
19
|
+
fields: (f) => ({ handle: f.string(), club: f.one(() => Club) }),
|
|
20
|
+
indexes: [{ fields: ['handle', 'club'], unique: true, mode: 'insensitive' }],
|
|
21
|
+
});
|
|
22
|
+
export function uniqueCases(create) {
|
|
23
|
+
describe('case-insensitive unique keys', () => {
|
|
24
|
+
started(create, [Account, Club, Member]);
|
|
25
|
+
it('refuses a value another row holds in another case', async () => {
|
|
26
|
+
const alice = await Account.create({ data: { username: 'alice' } }, M);
|
|
27
|
+
const refused = {
|
|
28
|
+
code: 'UNIQUE_VIOLATION',
|
|
29
|
+
details: { model: 'Account', fields: ['username'] },
|
|
30
|
+
};
|
|
31
|
+
await expect(Account.create({ data: { username: 'Alice' } }, M)).rejects.toMatchObject(refused);
|
|
32
|
+
await expect(Account.createMany({ data: [{ username: 'Bob' }, { username: 'BOB' }] }, M)).rejects.toMatchObject(refused);
|
|
33
|
+
const bob = await Account.create({ data: { username: 'bob' } }, M);
|
|
34
|
+
await expect(Account.update({ where: { id: bob.id }, data: { username: 'ALICE' } }, M)).rejects.toMatchObject(refused);
|
|
35
|
+
const renamed = await Account.update({ where: { id: alice.id }, data: { username: 'Alice' } }, M);
|
|
36
|
+
expect(renamed.username).toBe('Alice');
|
|
37
|
+
expect(await Account.count({}, M)).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
it('lets rows without a value share none', async () => {
|
|
40
|
+
await Account.create({ data: { username: 'ada' } }, M);
|
|
41
|
+
await Account.create({ data: { username: 'grace' } }, M);
|
|
42
|
+
await Account.create({ data: { username: 'linus', nickname: 'Penguin' } }, M);
|
|
43
|
+
await expect(Account.create({ data: { username: 'tux', nickname: 'penguin' } }, M)).rejects.toMatchObject({
|
|
44
|
+
details: { model: 'Account', fields: ['nickname'] },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it('matches exactly by key, and finds the other cases with mode', async () => {
|
|
48
|
+
const alice = await Account.create({ data: { username: 'Alice' } }, M);
|
|
49
|
+
expect(await Account.findUnique({ where: { username: 'alice' } }, M)).toBeNull();
|
|
50
|
+
expect(await Account.findUnique({ where: { username: 'Alice' } }, M)).toEqual(alice);
|
|
51
|
+
const insensitive = { equals: 'ALICE', mode: 'insensitive' };
|
|
52
|
+
expect(await Account.findFirst({ where: { username: insensitive } }, M)).toEqual(alice);
|
|
53
|
+
expect(await Account.count({ where: { username: { not: 'aLiCe', mode: 'insensitive' } } }, M)).toBe(0);
|
|
54
|
+
expect(await Account.count({ where: { username: { in: ['ALICE'], mode: 'insensitive' } } }, M)).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
it('refuses the values of an index in another case, its other fields alike', async () => {
|
|
57
|
+
const chess = await Club.create({ data: { name: 'Chess' } }, M);
|
|
58
|
+
const go = await Club.create({ data: { name: 'Go' } }, M);
|
|
59
|
+
await Member.create({ data: { handle: 'ada', club: club(chess.id) } }, M);
|
|
60
|
+
await expect(Member.create({ data: { handle: 'ADA', club: club(chess.id) } }, M)).rejects.toMatchObject({
|
|
61
|
+
code: 'UNIQUE_VIOLATION',
|
|
62
|
+
details: { model: 'Member', fields: ['handle', 'club'] },
|
|
63
|
+
});
|
|
64
|
+
await Member.create({ data: { handle: 'ADA', club: club(go.id) } }, M);
|
|
65
|
+
expect(await Member.count({}, M)).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-canary.20261031104207",
|
|
4
4
|
"description": "Test helpers for UltimaBase applications, plugins and adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"ultimabase": "
|
|
34
|
+
"ultimabase": "0.3.0-canary.20261031104207",
|
|
35
35
|
"vitest": "^5.0.5"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@ultimabase/http": "0.
|
|
39
|
-
"ultimabase": "0.
|
|
38
|
+
"@ultimabase/http": "0.3.0-canary.20261031104207",
|
|
39
|
+
"ultimabase": "0.3.0-canary.20261031104207",
|
|
40
40
|
"vite": "8.3.4",
|
|
41
41
|
"vitest": "5.0.5"
|
|
42
42
|
}
|
package/src/test-adapter.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { roundTrips } from './round-trips.ts'
|
|
|
23
23
|
import { searches } from './searches.ts'
|
|
24
24
|
import { sorts } from './sorts.ts'
|
|
25
25
|
import { transactions } from './transactions.ts'
|
|
26
|
+
import { uniqueCases } from './unique-cases.ts'
|
|
26
27
|
import { virtuals } from './virtuals.ts'
|
|
27
28
|
import { writes } from './writes.ts'
|
|
28
29
|
|
|
@@ -44,6 +45,7 @@ export function testAdapter(
|
|
|
44
45
|
reads(create)
|
|
45
46
|
cases(create)
|
|
46
47
|
writes(create)
|
|
48
|
+
uniqueCases(create)
|
|
47
49
|
cascades(create)
|
|
48
50
|
lists(create)
|
|
49
51
|
jsonPaths(create)
|
package/src/test-application.ts
CHANGED
|
@@ -4,12 +4,22 @@
|
|
|
4
4
|
import type { Adapter } from 'ultimabase'
|
|
5
5
|
import { memory } from 'ultimabase'
|
|
6
6
|
import type { Lifecycle } from 'ultimabase/internal'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
quietRuns,
|
|
9
|
+
runningApi,
|
|
10
|
+
runningFeed,
|
|
11
|
+
scratchOf,
|
|
12
|
+
} from 'ultimabase/internal'
|
|
8
13
|
import { afterEach, beforeAll, beforeEach } from 'vitest'
|
|
9
14
|
|
|
10
15
|
export type TestApplication = {
|
|
11
16
|
/** The HTTP server's address during a test, such as `http://localhost:54321`. */
|
|
12
17
|
readonly url: string
|
|
18
|
+
/** The change feed, during a test (D-168). */
|
|
19
|
+
readonly feed: {
|
|
20
|
+
/** Calls each plugin's `missed`, as after a lost connection. */
|
|
21
|
+
readonly missed: () => void
|
|
22
|
+
}
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/** Starts `run` with `PORT` set to `0`, then puts `PORT` back. */
|
|
@@ -89,5 +99,12 @@ export function testApplication(
|
|
|
89
99
|
}
|
|
90
100
|
return url
|
|
91
101
|
},
|
|
102
|
+
feed: {
|
|
103
|
+
missed: () => {
|
|
104
|
+
if (run === undefined)
|
|
105
|
+
throw new Error('feed.missed() runs during a test.')
|
|
106
|
+
runningFeed()?.missed()
|
|
107
|
+
},
|
|
108
|
+
},
|
|
92
109
|
}
|
|
93
110
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Case-insensitive unique keys in the contract (D-170): a field's and an
|
|
2
|
+
// index's, refused regardless of case, matched exactly by `findUnique` and
|
|
3
|
+
// found by an insensitive equality.
|
|
4
|
+
import type { Adapter } from 'ultimabase'
|
|
5
|
+
import { defineModel, master } from 'ultimabase'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { started } from './suite.ts'
|
|
8
|
+
|
|
9
|
+
const M = { as: master }
|
|
10
|
+
const club = (id: string) => ({ connect: { id } })
|
|
11
|
+
|
|
12
|
+
const Account = defineModel({
|
|
13
|
+
name: 'Account',
|
|
14
|
+
fields: (f) => ({
|
|
15
|
+
username: f.string({ unique: 'insensitive' }),
|
|
16
|
+
nickname: f.string({ unique: 'insensitive', optional: true }),
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const Club = defineModel({
|
|
21
|
+
name: 'Club',
|
|
22
|
+
fields: (f) => ({ name: f.string(), members: f.many(() => Member) }),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const Member = defineModel({
|
|
26
|
+
name: 'Member',
|
|
27
|
+
fields: (f) => ({ handle: f.string(), club: f.one(() => Club) }),
|
|
28
|
+
indexes: [{ fields: ['handle', 'club'], unique: true, mode: 'insensitive' }],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export function uniqueCases(create: () => Adapter): void {
|
|
32
|
+
describe('case-insensitive unique keys', () => {
|
|
33
|
+
started(create, [Account, Club, Member])
|
|
34
|
+
|
|
35
|
+
it('refuses a value another row holds in another case', async () => {
|
|
36
|
+
const alice = await Account.create({ data: { username: 'alice' } }, M)
|
|
37
|
+
const refused = {
|
|
38
|
+
code: 'UNIQUE_VIOLATION',
|
|
39
|
+
details: { model: 'Account', fields: ['username'] },
|
|
40
|
+
}
|
|
41
|
+
await expect(
|
|
42
|
+
Account.create({ data: { username: 'Alice' } }, M),
|
|
43
|
+
).rejects.toMatchObject(refused)
|
|
44
|
+
await expect(
|
|
45
|
+
Account.createMany(
|
|
46
|
+
{ data: [{ username: 'Bob' }, { username: 'BOB' }] },
|
|
47
|
+
M,
|
|
48
|
+
),
|
|
49
|
+
).rejects.toMatchObject(refused)
|
|
50
|
+
const bob = await Account.create({ data: { username: 'bob' } }, M)
|
|
51
|
+
await expect(
|
|
52
|
+
Account.update(
|
|
53
|
+
{ where: { id: bob.id }, data: { username: 'ALICE' } },
|
|
54
|
+
M,
|
|
55
|
+
),
|
|
56
|
+
).rejects.toMatchObject(refused)
|
|
57
|
+
const renamed = await Account.update(
|
|
58
|
+
{ where: { id: alice.id }, data: { username: 'Alice' } },
|
|
59
|
+
M,
|
|
60
|
+
)
|
|
61
|
+
expect(renamed.username).toBe('Alice')
|
|
62
|
+
expect(await Account.count({}, M)).toBe(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('lets rows without a value share none', async () => {
|
|
66
|
+
await Account.create({ data: { username: 'ada' } }, M)
|
|
67
|
+
await Account.create({ data: { username: 'grace' } }, M)
|
|
68
|
+
await Account.create(
|
|
69
|
+
{ data: { username: 'linus', nickname: 'Penguin' } },
|
|
70
|
+
M,
|
|
71
|
+
)
|
|
72
|
+
await expect(
|
|
73
|
+
Account.create({ data: { username: 'tux', nickname: 'penguin' } }, M),
|
|
74
|
+
).rejects.toMatchObject({
|
|
75
|
+
details: { model: 'Account', fields: ['nickname'] },
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('matches exactly by key, and finds the other cases with mode', async () => {
|
|
80
|
+
const alice = await Account.create({ data: { username: 'Alice' } }, M)
|
|
81
|
+
expect(
|
|
82
|
+
await Account.findUnique({ where: { username: 'alice' } }, M),
|
|
83
|
+
).toBeNull()
|
|
84
|
+
expect(
|
|
85
|
+
await Account.findUnique({ where: { username: 'Alice' } }, M),
|
|
86
|
+
).toEqual(alice)
|
|
87
|
+
const insensitive = { equals: 'ALICE', mode: 'insensitive' } as const
|
|
88
|
+
expect(
|
|
89
|
+
await Account.findFirst({ where: { username: insensitive } }, M),
|
|
90
|
+
).toEqual(alice)
|
|
91
|
+
expect(
|
|
92
|
+
await Account.count(
|
|
93
|
+
{ where: { username: { not: 'aLiCe', mode: 'insensitive' } } },
|
|
94
|
+
M,
|
|
95
|
+
),
|
|
96
|
+
).toBe(0)
|
|
97
|
+
expect(
|
|
98
|
+
await Account.count(
|
|
99
|
+
{ where: { username: { in: ['ALICE'], mode: 'insensitive' } } },
|
|
100
|
+
M,
|
|
101
|
+
),
|
|
102
|
+
).toBe(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('refuses the values of an index in another case, its other fields alike', async () => {
|
|
106
|
+
const chess = await Club.create({ data: { name: 'Chess' } }, M)
|
|
107
|
+
const go = await Club.create({ data: { name: 'Go' } }, M)
|
|
108
|
+
await Member.create({ data: { handle: 'ada', club: club(chess.id) } }, M)
|
|
109
|
+
await expect(
|
|
110
|
+
Member.create({ data: { handle: 'ADA', club: club(chess.id) } }, M),
|
|
111
|
+
).rejects.toMatchObject({
|
|
112
|
+
code: 'UNIQUE_VIOLATION',
|
|
113
|
+
details: { model: 'Member', fields: ['handle', 'club'] },
|
|
114
|
+
})
|
|
115
|
+
await Member.create({ data: { handle: 'ADA', club: club(go.id) } }, M)
|
|
116
|
+
expect(await Member.count({}, M)).toBe(2)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|