@stjoseph/prumo 0.5.0 → 0.6.0
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/.prumo-templates/api/auth.md +1 -1
- package/.prumo-templates/core/testing.md +14 -4
- package/.prumo-templates/mcp/server.md +1 -1
- package/.prumo-templates/monorepo/layout.md +5 -1
- package/.prumo-templates/monorepo/tasks.md +5 -3
- package/.prumo-templates/social/sign-in.md +2 -1
- package/README.md +15 -2
- package/dist/add.js +219 -0
- package/dist/apps.js +205 -0
- package/dist/asker.js +37 -0
- package/dist/clean.js +3 -3
- package/dist/cli.js +51 -2
- package/dist/commands.js +10 -0
- package/dist/compose.js +86 -28
- package/dist/context.js +32 -5
- package/dist/database.js +3 -4
- package/dist/generate.js +26 -24
- package/dist/names.js +15 -2
- package/dist/next-steps.js +4 -3
- package/dist/pnpm-extended.js +159 -0
- package/dist/questions.js +22 -3
- package/dist/workspace-yaml.js +91 -0
- package/package.json +4 -1
- package/templates/api/.env.example +1 -0
- package/templates/api/README.md +2 -1
- package/templates/api/scripts/ports.mjs +60 -45
- package/templates/api/src/infra/config/env.ts +8 -1
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
- package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
- package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
- package/templates/api/test/infra/config/env.spec.ts +26 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
- package/templates/api/test/support/setup.ts +1 -1
- package/templates/mobile/scripts/ports.mjs +60 -45
- package/templates/mobile/src/api-contract/auth.ts +15 -3
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +3 -2
- package/templates/mobile/test/features/profile/profile-form.spec.tsx +3 -4
- package/templates/mobile/test/support/query-client.ts +14 -0
- package/templates/site/scripts/ports.mjs +60 -45
- package/templates/web/scripts/ports.mjs +60 -45
- package/templates/web/src/api-contract/auth.ts +15 -3
- package/templates/web/src/features/auth/social-buttons.tsx +2 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +3 -2
- package/templates/web/test/features/profile/profile-form.spec.tsx +3 -2
- package/templates/web/test/support/query-client.ts +14 -0
- package/templates/web/test/support/render-app.tsx +2 -2
- package/templates/web/vite.config.ts +10 -3
- package/templates/workspace/README.md +2 -2
- package/templates/workspace/scripts/ports.mjs +60 -45
|
@@ -4,9 +4,9 @@ import { HttpError } from '@/infra/http/errors/http-error'
|
|
|
4
4
|
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
5
5
|
|
|
6
6
|
// A cookie is sent by the browser whoever wrote the page, so a request that changes something and leans on one must
|
|
7
|
-
// come from the web
|
|
8
|
-
// client send a bearer, and pass.
|
|
9
|
-
export function registerOriginCheck(app: FastifyInstance,
|
|
7
|
+
// come from one of the web apps. Without a cookie there is nothing for another site to borrow: a native app and an
|
|
8
|
+
// MCP client send a bearer, and pass.
|
|
9
|
+
export function registerOriginCheck(app: FastifyInstance, webOrigins: string[]): void {
|
|
10
10
|
app.addHook('onRequest', async (request) => {
|
|
11
11
|
const leansOnCookie =
|
|
12
12
|
request.headers.cookie !== undefined && request.headers.authorization === undefined
|
|
@@ -15,7 +15,7 @@ export function registerOriginCheck(app: FastifyInstance, webOrigin: string): vo
|
|
|
15
15
|
return
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
if (request.headers.origin
|
|
18
|
+
if (request.headers.origin === undefined || !webOrigins.includes(request.headers.origin)) {
|
|
19
19
|
throw new HttpError(403, 'Cross-origin request refused')
|
|
20
20
|
}
|
|
21
21
|
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { validate } from '@/infra/config/env'
|
|
3
|
+
|
|
4
|
+
const RAW = {
|
|
5
|
+
NODE_ENV: 'test',
|
|
6
|
+
PORT: '3000',
|
|
7
|
+
DATABASE_URL: 'postgres://app:app@localhost:5432/app',
|
|
8
|
+
JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
9
|
+
API_URL: 'http://localhost:3000',
|
|
10
|
+
LOG_LEVEL: 'error',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('WEB_ORIGIN', () => {
|
|
14
|
+
it('holds every web app, comma-separated, written as a browser sends its origin', () => {
|
|
15
|
+
const env = validate({ ...RAW, WEB_ORIGIN: 'http://localhost:5173, http://localhost:5174/' })
|
|
16
|
+
|
|
17
|
+
expect(env.WEB_ORIGIN).toEqual(['http://localhost:5173', 'http://localhost:5174'])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('refuses an empty list and an entry that is not a URL', () => {
|
|
21
|
+
expect(() => validate({ ...RAW, WEB_ORIGIN: '' })).toThrow('WEB_ORIGIN')
|
|
22
|
+
expect(() => validate({ ...RAW, WEB_ORIGIN: 'http://localhost:5173,backoffice' })).toThrow(
|
|
23
|
+
'WEB_ORIGIN',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testEnv } from '@test/support/setup'
|
|
1
2
|
import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
|
|
2
3
|
import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
|
|
3
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
@@ -83,6 +84,29 @@ describe('auth controller, on the web', () => {
|
|
|
83
84
|
})
|
|
84
85
|
})
|
|
85
86
|
|
|
87
|
+
describe('auth controller, with several web apps', () => {
|
|
88
|
+
it('lets each of them lean on its cookie, and tells the browser so', async () => {
|
|
89
|
+
const backoffice = 'http://localhost:5174'
|
|
90
|
+
const several = await testApp({ env: { ...testEnv(), WEB_ORIGIN: [ORIGIN, backoffice] } })
|
|
91
|
+
const cookie = `access_token=${cookiesOf((await several.signUp()).response).access_token}`
|
|
92
|
+
const signedOut = await several.app.inject({
|
|
93
|
+
method: 'POST',
|
|
94
|
+
url: '/api/auth/sign-out',
|
|
95
|
+
headers: { cookie, origin: backoffice },
|
|
96
|
+
})
|
|
97
|
+
const preflight = await several.app.inject({
|
|
98
|
+
method: 'OPTIONS',
|
|
99
|
+
url: '/api/auth/sign-out',
|
|
100
|
+
headers: { origin: backoffice, 'access-control-request-method': 'POST' },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(signedOut.statusCode).toBe(204)
|
|
104
|
+
expect(preflight.headers['access-control-allow-origin']).toBe(backoffice)
|
|
105
|
+
|
|
106
|
+
await several.app.close()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
86
110
|
describe('auth controller, for a native client', () => {
|
|
87
111
|
it('hands the tokens over in the body, and accepts them as a bearer', async () => {
|
|
88
112
|
const { response } = await signUp({ bearer: true })
|
|
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
6
6
|
import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
|
|
7
7
|
|
|
8
8
|
const WEB = 'http://localhost:5173'
|
|
9
|
+
const BACKOFFICE = 'http://localhost:5174'
|
|
9
10
|
|
|
10
11
|
// Whichever provider the project kept: the flow is the same for each.
|
|
11
12
|
const PROVIDER = [
|
|
@@ -26,7 +27,7 @@ function stateOf(response: LightMyRequestResponse): string {
|
|
|
26
27
|
|
|
27
28
|
beforeEach(async () => {
|
|
28
29
|
;({ app } = await testApp({
|
|
29
|
-
env: { ...testEnv(), MOBILE_APP_SCHEME: 'acme' },
|
|
30
|
+
env: { ...testEnv(), WEB_ORIGIN: [WEB, BACKOFFICE], MOBILE_APP_SCHEME: 'acme' },
|
|
30
31
|
configure: (container) =>
|
|
31
32
|
container.register(IDENTITY_PROVIDERS, { useValue: new FakeIdentityProviders() }),
|
|
32
33
|
}))
|
|
@@ -69,6 +70,27 @@ describe('social sign-in on the web', () => {
|
|
|
69
70
|
expect(back.headers.location).toBe(`${WEB}/`)
|
|
70
71
|
})
|
|
71
72
|
|
|
73
|
+
it('sends each web app back to itself, and refuses an origin it does not trust', async () => {
|
|
74
|
+
const started = await app.inject({
|
|
75
|
+
url: `/api/auth/social/${PROVIDER}?returnTo=/orders&origin=${encodeURIComponent(BACKOFFICE)}`,
|
|
76
|
+
})
|
|
77
|
+
const back = await app.inject({
|
|
78
|
+
url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
|
|
79
|
+
headers: { cookie: browserCookie(started) },
|
|
80
|
+
})
|
|
81
|
+
const failed = await app.inject({
|
|
82
|
+
url: `/api/auth/social/${PROVIDER}/callback?error=access_denied`,
|
|
83
|
+
headers: { cookie: browserCookie(started) },
|
|
84
|
+
})
|
|
85
|
+
const foreign = await app.inject({
|
|
86
|
+
url: `/api/auth/social/${PROVIDER}?origin=${encodeURIComponent('https://evil.example')}`,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(back.headers.location).toBe(`${BACKOFFICE}/orders`)
|
|
90
|
+
expect(failed.headers.location).toBe(`${BACKOFFICE}/sign-in?error=social_sign_in_failed`)
|
|
91
|
+
expect(foreign.statusCode).toBe(422)
|
|
92
|
+
})
|
|
93
|
+
|
|
72
94
|
it('turns a form post, as Apple sends, into a redirect the browser’s cookie comes with', async () => {
|
|
73
95
|
const posted = await app.inject({
|
|
74
96
|
method: 'POST',
|
|
@@ -25,7 +25,7 @@ export function testEnv(): Env {
|
|
|
25
25
|
DATABASE_URL: databaseUrl,
|
|
26
26
|
JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
27
27
|
API_URL: 'http://localhost:3000',
|
|
28
|
-
WEB_ORIGIN: 'http://localhost:5173',
|
|
28
|
+
WEB_ORIGIN: ['http://localhost:5173'],
|
|
29
29
|
LOG_LEVEL: 'error',
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -14,22 +14,23 @@ import { basename, dirname, join, resolve } from 'node:path'
|
|
|
14
14
|
import { createInterface } from 'node:readline/promises'
|
|
15
15
|
import { parseArgs } from 'node:util'
|
|
16
16
|
|
|
17
|
-
// Where
|
|
18
|
-
const
|
|
17
|
+
// Where an app of each type keeps its port, what it falls back to, and how its dev command is told about it.
|
|
18
|
+
const TYPES = {
|
|
19
19
|
api: { key: 'PORT', fallback: 3000, flag: [] },
|
|
20
20
|
web: { key: 'WEB_PORT', fallback: 5173, flag: ['--port', '--strictPort'] },
|
|
21
21
|
site: { key: 'SITE_PORT', fallback: 3200, flag: ['--port'] },
|
|
22
22
|
mobile: { key: 'METRO_PORT', fallback: 8081, flag: ['--port'] },
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// A port that moves takes these with it: the key to write, in
|
|
25
|
+
// A port that moves takes these with it: the key to write, in every app of which type. A list holds one URL per app,
|
|
26
|
+
// so only the moving app's entry changes.
|
|
26
27
|
const FOLLOWERS = {
|
|
27
28
|
api: [
|
|
28
|
-
{
|
|
29
|
-
{
|
|
30
|
-
{
|
|
29
|
+
{ type: 'api', key: 'API_URL' },
|
|
30
|
+
{ type: 'web', key: 'VITE_API_URL' },
|
|
31
|
+
{ type: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
32
|
],
|
|
32
|
-
web: [{
|
|
33
|
+
web: [{ type: 'api', key: 'WEB_ORIGIN', list: true }],
|
|
33
34
|
site: [],
|
|
34
35
|
mobile: [],
|
|
35
36
|
}
|
|
@@ -117,36 +118,43 @@ function projectRoot() {
|
|
|
117
118
|
const root = projectRoot()
|
|
118
119
|
const workspace = existsSync(join(root, 'apps'))
|
|
119
120
|
|
|
120
|
-
function
|
|
121
|
-
|
|
121
|
+
function readConfig() {
|
|
122
|
+
const path = join(root, '.prumo', 'config.json')
|
|
123
|
+
|
|
124
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// Every app of the project, as { name, type, directory }. A workspace lists its apps in .prumo/config.json; one written
|
|
128
|
+
// before apps had names holds one per type, named after it. An alone project is its one app, named after its type.
|
|
129
|
+
function projectApps() {
|
|
130
|
+
const config = readConfig()
|
|
131
|
+
|
|
132
|
+
if (!workspace) {
|
|
133
|
+
// A project is named by its author, so only .prumo/config.json says what it is.
|
|
134
|
+
const type = TYPES[basename(root)] !== undefined ? basename(root) : config.types?.[0]
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
return name
|
|
136
|
+
return type === undefined ? [] : [{ name: type, type, directory: root }]
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
const config = join(root, '.prumo', 'config.json')
|
|
133
|
-
const types = existsSync(config) ? JSON.parse(readFileSync(config, 'utf8')).types : []
|
|
139
|
+
const listed = config.apps ?? (config.types ?? []).map((type) => ({ name: type, type }))
|
|
134
140
|
|
|
135
|
-
return
|
|
141
|
+
return listed
|
|
142
|
+
.map((app) => ({ ...app, directory: join(root, 'apps', app.name) }))
|
|
143
|
+
.filter(
|
|
144
|
+
(app) => TYPES[app.type] !== undefined && existsSync(join(app.directory, 'package.json')),
|
|
145
|
+
)
|
|
136
146
|
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
if (!values.all) {
|
|
140
|
-
return [typeOf(here)]
|
|
141
|
-
}
|
|
148
|
+
const apps = projectApps()
|
|
142
149
|
|
|
143
|
-
|
|
150
|
+
function appsToCheck() {
|
|
151
|
+
return values.all ? apps : apps.filter((app) => app.directory === here)
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
function portOf(
|
|
147
|
-
const
|
|
154
|
+
function portOf(app) {
|
|
155
|
+
const { key, fallback } = TYPES[app.type]
|
|
148
156
|
|
|
149
|
-
return Number(envValue(readEnv(
|
|
157
|
+
return Number(envValue(readEnv(app.directory), key) ?? fallback)
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
function listening(host, port) {
|
|
@@ -269,22 +277,31 @@ async function nextFree(from) {
|
|
|
269
277
|
throw new Failure('port_busy', `No free port between ${from + 1} and ${from + PORTS_TRIED - 1}.`)
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
function movePort(
|
|
273
|
-
const
|
|
280
|
+
function movePort(app, from, port) {
|
|
281
|
+
const before = `http://localhost:${from}`
|
|
282
|
+
const after = `http://localhost:${port}`
|
|
274
283
|
|
|
275
|
-
writeEnvValue(
|
|
284
|
+
writeEnvValue(app.directory, TYPES[app.type].key, port)
|
|
276
285
|
|
|
277
|
-
for (const follower of FOLLOWERS[type]) {
|
|
278
|
-
const
|
|
286
|
+
for (const follower of FOLLOWERS[app.type]) {
|
|
287
|
+
for (const other of apps.filter((each) => each.type === follower.type)) {
|
|
288
|
+
const current = envValue(readEnv(other.directory), follower.key)
|
|
289
|
+
const entries = (current ?? '').split(',').map((entry) => entry.trim())
|
|
290
|
+
const value =
|
|
291
|
+
follower.list && current !== undefined
|
|
292
|
+
? (entries.includes(before)
|
|
293
|
+
? entries.map((entry) => (entry === before ? after : entry))
|
|
294
|
+
: [...entries, after]
|
|
295
|
+
).join(',')
|
|
296
|
+
: after
|
|
279
297
|
|
|
280
|
-
|
|
281
|
-
writeEnvValue(directory, follower.key, `http://localhost:${port}`)
|
|
298
|
+
writeEnvValue(other.directory, follower.key, value)
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
}
|
|
285
302
|
|
|
286
|
-
async function settle(
|
|
287
|
-
const port = portOf(
|
|
303
|
+
async function settle(app) {
|
|
304
|
+
const port = portOf(app)
|
|
288
305
|
|
|
289
306
|
if (!(await taken(port))) {
|
|
290
307
|
return port
|
|
@@ -292,12 +309,12 @@ async function settle(type) {
|
|
|
292
309
|
|
|
293
310
|
const found = holders(port)
|
|
294
311
|
|
|
295
|
-
say(`${
|
|
312
|
+
say(`${app.name} wants port ${port}, held by ${describe(found)}.`)
|
|
296
313
|
|
|
297
314
|
const moved = async () => {
|
|
298
315
|
const free = await nextFree(port)
|
|
299
|
-
movePort(
|
|
300
|
-
say(`${
|
|
316
|
+
movePort(app, port, free)
|
|
317
|
+
say(`${app.name} moved to port ${free}, written to .env.`)
|
|
301
318
|
return free
|
|
302
319
|
}
|
|
303
320
|
|
|
@@ -311,7 +328,7 @@ async function settle(type) {
|
|
|
311
328
|
if (!interactive) {
|
|
312
329
|
throw new Failure(
|
|
313
330
|
'port_busy',
|
|
314
|
-
`Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${
|
|
331
|
+
`Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${app.name} to the next free port.`,
|
|
315
332
|
)
|
|
316
333
|
}
|
|
317
334
|
|
|
@@ -669,10 +686,8 @@ async function main() {
|
|
|
669
686
|
const starting = command === undefined ? undefined : startJob()
|
|
670
687
|
|
|
671
688
|
try {
|
|
672
|
-
for (const
|
|
673
|
-
|
|
674
|
-
ports[type] = await settle(type)
|
|
675
|
-
}
|
|
689
|
+
for (const app of appsToCheck()) {
|
|
690
|
+
ports[app.name] = await settle(app)
|
|
676
691
|
}
|
|
677
692
|
} catch (error) {
|
|
678
693
|
;(await starting)?.close()
|
|
@@ -685,10 +700,10 @@ async function main() {
|
|
|
685
700
|
|
|
686
701
|
// Only an app tells its command which port to use. A workspace root wraps `pnpm`, which would pass a stray
|
|
687
702
|
// `--port` on to every app it runs.
|
|
688
|
-
const [
|
|
689
|
-
const flag =
|
|
703
|
+
const [app] = values.all ? [] : appsToCheck()
|
|
704
|
+
const flag = app === undefined ? [] : TYPES[app.type].flag
|
|
690
705
|
const withPort = flag.flatMap((entry) =>
|
|
691
|
-
entry === '--port' ? ['--port', String(ports[
|
|
706
|
+
entry === '--port' ? ['--port', String(ports[app.name])] : [entry],
|
|
692
707
|
)
|
|
693
708
|
// Before the command starts, so that it and everything it starts are members from their first instant.
|
|
694
709
|
const job = await starting
|
|
@@ -178,9 +178,21 @@ export function createAuthClient({
|
|
|
178
178
|
// prumo:end-email
|
|
179
179
|
// prumo:social
|
|
180
180
|
// Where to send the browser to sign in with a provider: the API takes it there and back. The web comes back
|
|
181
|
-
// signed in; a mobile app comes back with a code to exchange.
|
|
182
|
-
socialSignInUrl: (
|
|
183
|
-
|
|
181
|
+
// signed in, to the origin it names when the API trusts several; a mobile app comes back with a code to exchange.
|
|
182
|
+
socialSignInUrl: (
|
|
183
|
+
provider: SocialProvider,
|
|
184
|
+
client: 'web' | 'mobile',
|
|
185
|
+
returnTo = '/',
|
|
186
|
+
origin?: string,
|
|
187
|
+
) => {
|
|
188
|
+
const query = new URLSearchParams({ client, returnTo })
|
|
189
|
+
|
|
190
|
+
if (origin !== undefined) {
|
|
191
|
+
query.set('origin', origin)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `${url(`/social/${provider}`)}?${query}`
|
|
195
|
+
},
|
|
184
196
|
|
|
185
197
|
exchangeSocialCode: async (code: string): Promise<void> => {
|
|
186
198
|
await begin('/social/exchange', { code })
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
3
3
|
import { memoryTokenStore } from '@test/support/memory-token-store'
|
|
4
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
4
5
|
import { renderRouter, screen } from 'expo-router/testing-library'
|
|
5
6
|
import type { ReactNode } from 'react'
|
|
6
7
|
import { Text } from 'react-native'
|
|
@@ -23,7 +24,7 @@ async function renderAt(getSession: () => Response | Promise<Response>) {
|
|
|
23
24
|
tokens: memoryTokenStore(TOKENS),
|
|
24
25
|
})
|
|
25
26
|
const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
|
|
26
|
-
const queryClient =
|
|
27
|
+
const queryClient = createTestQueryClient()
|
|
27
28
|
|
|
28
29
|
function Providers({ children }: { children: ReactNode }) {
|
|
29
30
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
3
3
|
import { memoryTokenStore } from '@test/support/memory-token-store'
|
|
4
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
4
5
|
import { userEvent } from '@testing-library/react-native'
|
|
5
6
|
import { renderRouter, screen } from 'expo-router/testing-library'
|
|
6
7
|
import type { ReactNode } from 'react'
|
|
@@ -18,7 +19,7 @@ async function renderAt(initialUrl: string, routes: Parameters<typeof fakeTransp
|
|
|
18
19
|
const transport = fakeTransport(routes)
|
|
19
20
|
const auth = createAuthClient({ baseUrl: API_URL, fetch: transport, tokens: memoryTokenStore() })
|
|
20
21
|
const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
|
|
21
|
-
const queryClient =
|
|
22
|
+
const queryClient = createTestQueryClient()
|
|
22
23
|
|
|
23
24
|
function Providers({ children }: { children: ReactNode }) {
|
|
24
25
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
3
3
|
import { memoryTokenStore } from '@test/support/memory-token-store'
|
|
4
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
4
5
|
import { render, screen, userEvent } from '@testing-library/react-native'
|
|
5
6
|
import { createAuthClient } from '@/api-contract'
|
|
6
7
|
import { SignInForm } from '@/features/auth/sign-in-form'
|
|
@@ -23,7 +24,7 @@ describe('SignInForm', () => {
|
|
|
23
24
|
const user = userEvent.setup()
|
|
24
25
|
|
|
25
26
|
await render(
|
|
26
|
-
<QueryClientProvider client={
|
|
27
|
+
<QueryClientProvider client={createTestQueryClient()}>
|
|
27
28
|
<SignInForm auth={auth} onSignedIn={onSignedIn} />
|
|
28
29
|
</QueryClientProvider>,
|
|
29
30
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport } from '@test/support/fake-transport'
|
|
3
3
|
import { memoryTokenStore } from '@test/support/memory-token-store'
|
|
4
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
4
5
|
import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
|
|
5
6
|
import { createAuthClient } from '@/api-contract'
|
|
6
7
|
import { SignOutButton } from '@/features/auth/sign-out-button'
|
|
@@ -10,7 +11,7 @@ import { clearUserData } from '@/features/storage/storage'
|
|
|
10
11
|
jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
|
|
11
12
|
|
|
12
13
|
async function signOutWith(signOut: () => Response | Promise<Response>) {
|
|
13
|
-
const queryClient =
|
|
14
|
+
const queryClient = createTestQueryClient()
|
|
14
15
|
const onSignedOut = jest.fn()
|
|
15
16
|
const auth = createAuthClient({
|
|
16
17
|
baseUrl: API_URL,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
3
3
|
import { memoryTokenStore } from '@test/support/memory-token-store'
|
|
4
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
4
5
|
import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
|
|
5
6
|
import * as WebBrowser from 'expo-web-browser'
|
|
6
7
|
import { createAuthClient } from '@/api-contract'
|
|
@@ -28,7 +29,7 @@ async function renderButtons(exchange: () => Response) {
|
|
|
28
29
|
})
|
|
29
30
|
|
|
30
31
|
await render(
|
|
31
|
-
<QueryClientProvider client={
|
|
32
|
+
<QueryClientProvider client={createTestQueryClient()}>
|
|
32
33
|
<SocialButtons auth={auth} onSignedIn={onSignedIn} />
|
|
33
34
|
</QueryClientProvider>,
|
|
34
35
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
2
|
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
3
|
+
import { createTestQueryClient } from '@test/support/query-client'
|
|
3
4
|
import { render, screen, userEvent, within } from '@testing-library/react-native'
|
|
4
5
|
import { createClient, type Profile } from '@/api-contract'
|
|
5
6
|
import { ProfileForm } from '@/features/profile/profile-form'
|
|
@@ -36,9 +37,7 @@ describe('ProfileForm', () => {
|
|
|
36
37
|
}),
|
|
37
38
|
})
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
// it out. Infinity schedules none, as TanStack Query's testing guide advises.
|
|
41
|
-
const queryClient = new QueryClient({ defaultOptions: { mutations: { gcTime: Infinity } } })
|
|
40
|
+
const queryClient = createTestQueryClient()
|
|
42
41
|
|
|
43
42
|
await render(
|
|
44
43
|
<QueryClientProvider client={queryClient}>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
// Every test builds its QueryClient here, with the defaults TanStack Query's testing guide gives. A failed query
|
|
4
|
+
// fails at once instead of retrying three times with backoff. And gcTime Infinity schedules no garbage-collection
|
|
5
|
+
// timer: once a test unmounts, a query or mutation left without an observer would otherwise start a real five-minute
|
|
6
|
+
// setTimeout, and Jest's worker waits on it until it is force-exited.
|
|
7
|
+
export function createTestQueryClient(): QueryClient {
|
|
8
|
+
return new QueryClient({
|
|
9
|
+
defaultOptions: {
|
|
10
|
+
queries: { retry: false, gcTime: Infinity },
|
|
11
|
+
mutations: { gcTime: Infinity },
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
}
|