test(api): setup Japa + tests fonctionnels auth (signup/login/logout/onboarding)
Setup : - .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite). - Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx. - helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir. Tests fonctionnels auth (tests/functional/auth.spec.ts) : - Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris - Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu - Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide - Logout : révoque le token courant, requêtes suivantes en 401 - Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite Pour lancer : `pnpm -F api test`
This commit is contained in:
parent
01f3edcf08
commit
fc66d80f56
@ -1 +1,12 @@
|
|||||||
|
NODE_ENV=test
|
||||||
SESSION_DRIVER=memory
|
SESSION_DRIVER=memory
|
||||||
|
# Désactive les vraies connexions Redis/MinIO/SMTP pendant les tests.
|
||||||
|
# Les schedulers détectent NODE_ENV=test et skip BullMQ.add.
|
||||||
|
DRIVE_DISK=fs
|
||||||
|
MAIL_DRIVER=smtp
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=1025
|
||||||
|
OCR_PROVIDER=mock
|
||||||
|
# Utilise la même DB que dev avec global transactions par test (rollback).
|
||||||
|
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
|
||||||
|
# PG_DB_NAME=rubis_test ici.
|
||||||
|
|||||||
@ -3,10 +3,15 @@ import CheckinTask from '#models/checkin_task'
|
|||||||
import Invoice from '#models/invoice'
|
import Invoice from '#models/invoice'
|
||||||
import { getQueue } from '#services/queue'
|
import { getQueue } from '#services/queue'
|
||||||
import { generateCheckinToken } from '#services/checkin_token'
|
import { generateCheckinToken } from '#services/checkin_token'
|
||||||
|
import app from '@adonisjs/core/services/app'
|
||||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||||
|
|
||||||
const CHECKIN_QUEUE = 'checkins'
|
const CHECKIN_QUEUE = 'checkins'
|
||||||
|
|
||||||
|
function shouldEnqueue(): boolean {
|
||||||
|
return app.getEnvironment() !== 'test'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Programme un check-in pour une facture.
|
* Programme un check-in pour une facture.
|
||||||
*
|
*
|
||||||
@ -22,6 +27,9 @@ const CHECKIN_QUEUE = 'checkins'
|
|||||||
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
|
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
|
||||||
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
|
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
|
||||||
* après changement de dueDate).
|
* après changement de dueDate).
|
||||||
|
*
|
||||||
|
* En tests : la task DB est créée mais l'enqueue BullMQ est skippé
|
||||||
|
* (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon).
|
||||||
*/
|
*/
|
||||||
export async function scheduleCheckinForInvoice(
|
export async function scheduleCheckinForInvoice(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
@ -31,9 +39,9 @@ export async function scheduleCheckinForInvoice(
|
|||||||
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
|
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
|
||||||
.where('invoice_id', invoice.id)
|
.where('invoice_id', invoice.id)
|
||||||
.where('status', 'scheduled')
|
.where('status', 'scheduled')
|
||||||
const queue = getQueue(CHECKIN_QUEUE)
|
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
await queue.remove(`checkin-${t.id}`).catch(() => {})
|
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
|
||||||
t.useTransaction(trx ?? (null as never))
|
t.useTransaction(trx ?? (null as never))
|
||||||
t.status = 'expired'
|
t.status = 'expired'
|
||||||
await t.save()
|
await t.save()
|
||||||
@ -59,17 +67,19 @@ export async function scheduleCheckinForInvoice(
|
|||||||
trx ? { client: trx } : undefined
|
trx ? { client: trx } : undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
if (queue) {
|
||||||
await queue.add(
|
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
||||||
'send-checkin',
|
await queue.add(
|
||||||
{ taskId: task.id, plain },
|
'send-checkin',
|
||||||
{
|
{ taskId: task.id, plain },
|
||||||
delay,
|
{
|
||||||
jobId: `checkin-${task.id}`,
|
delay,
|
||||||
attempts: 3,
|
jobId: `checkin-${task.id}`,
|
||||||
backoff: { type: 'exponential', delay: 30_000 },
|
attempts: 3,
|
||||||
}
|
backoff: { type: 'exponential', delay: 30_000 },
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return { task, plain }
|
return { task, plain }
|
||||||
}
|
}
|
||||||
@ -86,9 +96,9 @@ export async function cancelCheckinForInvoice(
|
|||||||
.where('status', 'scheduled')
|
.where('status', 'scheduled')
|
||||||
if (tasks.length === 0) return
|
if (tasks.length === 0) return
|
||||||
|
|
||||||
const queue = getQueue(CHECKIN_QUEUE)
|
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
await queue.remove(`checkin-${t.id}`).catch(() => {})
|
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
|
||||||
t.useTransaction(trx ?? (null as never))
|
t.useTransaction(trx ?? (null as never))
|
||||||
t.status = 'expired'
|
t.status = 'expired'
|
||||||
await t.save()
|
await t.save()
|
||||||
|
|||||||
@ -3,10 +3,21 @@ import RelanceTask from '#models/relance_task'
|
|||||||
import Plan from '#models/plan'
|
import Plan from '#models/plan'
|
||||||
import Invoice from '#models/invoice'
|
import Invoice from '#models/invoice'
|
||||||
import { getQueue } from '#services/queue'
|
import { getQueue } from '#services/queue'
|
||||||
|
import app from '@adonisjs/core/services/app'
|
||||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||||
|
|
||||||
const RELANCE_QUEUE = 'relances'
|
const RELANCE_QUEUE = 'relances'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
|
||||||
|
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
|
||||||
|
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
|
||||||
|
* Redis live pour tourner les tests.
|
||||||
|
*/
|
||||||
|
function shouldEnqueue(): boolean {
|
||||||
|
return app.getEnvironment() !== 'test'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Programme toutes les relances d'une facture selon son plan.
|
* Programme toutes les relances d'une facture selon son plan.
|
||||||
*
|
*
|
||||||
@ -40,9 +51,9 @@ export async function scheduleRelancesForInvoice(
|
|||||||
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
|
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
|
||||||
.where('invoice_id', invoice.id)
|
.where('invoice_id', invoice.id)
|
||||||
.where('status', 'scheduled')
|
.where('status', 'scheduled')
|
||||||
const queue = getQueue(RELANCE_QUEUE)
|
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
if (t.queueJobId) {
|
if (t.queueJobId && queue) {
|
||||||
await queue.remove(t.queueJobId).catch(() => {
|
await queue.remove(t.queueJobId).catch(() => {
|
||||||
// Ignore — le job peut déjà être consommé.
|
// Ignore — le job peut déjà être consommé.
|
||||||
})
|
})
|
||||||
@ -73,22 +84,24 @@ export async function scheduleRelancesForInvoice(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
||||||
const job = await queue.add(
|
const job = queue
|
||||||
'send-relance',
|
? await queue.add(
|
||||||
{ taskId: task.id },
|
'send-relance',
|
||||||
{
|
{ taskId: task.id },
|
||||||
delay,
|
{
|
||||||
// Idempotency : un seul job actif par task.
|
delay,
|
||||||
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
|
// Idempotency : un seul job actif par task.
|
||||||
jobId: `relance-${task.id}`,
|
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
|
||||||
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
|
jobId: `relance-${task.id}`,
|
||||||
// backoff (cf. backend.md §13.2).
|
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
|
||||||
attempts: 5,
|
// backoff (cf. backend.md §13.2).
|
||||||
backoff: { type: 'exponential', delay: 30_000 },
|
attempts: 5,
|
||||||
}
|
backoff: { type: 'exponential', delay: 30_000 },
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
task.queueJobId = job.id ?? null
|
task.queueJobId = job?.id ?? null
|
||||||
await task.save()
|
await task.save()
|
||||||
created.push(task)
|
created.push(task)
|
||||||
}
|
}
|
||||||
@ -110,9 +123,9 @@ export async function cancelFutureRelances(
|
|||||||
.where('status', 'scheduled')
|
.where('status', 'scheduled')
|
||||||
if (tasks.length === 0) return
|
if (tasks.length === 0) return
|
||||||
|
|
||||||
const queue = getQueue(RELANCE_QUEUE)
|
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.queueJobId) {
|
if (t.queueJobId && queue) {
|
||||||
await queue.remove(t.queueJobId).catch(() => {})
|
await queue.remove(t.queueJobId).catch(() => {})
|
||||||
}
|
}
|
||||||
t.useTransaction(trx ?? null as never)
|
t.useTransaction(trx ?? null as never)
|
||||||
|
|||||||
180
apps/api/tests/functional/auth.spec.ts
Normal file
180
apps/api/tests/functional/auth.spec.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
|
import User from '#models/user'
|
||||||
|
import Plan from '#models/plan'
|
||||||
|
import { createTestUser } from '../helpers/auth.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'auth : signup, login, refresh, logout, perms.
|
||||||
|
*
|
||||||
|
* `withGlobalTransaction` wrap chaque test dans une tx PG qui est
|
||||||
|
* rollback à la fin → pas besoin de truncate, isolation parfaite.
|
||||||
|
*/
|
||||||
|
test.group('Auth — POST /auth/signup', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('crée user + org + 4 plans pré-fournis dans une tx', async ({ client, assert }) => {
|
||||||
|
const response = await client.post('/api/v1/auth/signup').json({
|
||||||
|
email: 'alice@spec.test',
|
||||||
|
password: 'password123',
|
||||||
|
fullName: 'Alice Spec',
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(201)
|
||||||
|
const data = response.body().data
|
||||||
|
assert.properties(data, ['accessToken', 'expiresAt', 'user'])
|
||||||
|
assert.properties(data.user, ['id', 'email', 'organizationId', 'fullName'])
|
||||||
|
assert.equal(data.user.email, 'alice@spec.test')
|
||||||
|
|
||||||
|
// L'org est créée mais le nom reste vide (rempli en onboarding).
|
||||||
|
const user = await User.findByOrFail('email', 'alice@spec.test')
|
||||||
|
assert.isNotNull(user.organizationId)
|
||||||
|
|
||||||
|
// Les 4 plans pré-fournis sont en place pour cette org.
|
||||||
|
const plans = await Plan.query().where('organization_id', user.organizationId!)
|
||||||
|
assert.lengthOf(plans, 4)
|
||||||
|
const slugs = plans.map((p) => p.slug).sort()
|
||||||
|
assert.deepEqual(slugs, ['ferme-7j', 'patient-60j', 'rapide-15j', 'standard-30j'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuse un email mal formé (422)', async ({ client }) => {
|
||||||
|
const response = await client.post('/api/v1/auth/signup').json({
|
||||||
|
email: 'pas-un-email',
|
||||||
|
password: 'password123',
|
||||||
|
fullName: 'Test',
|
||||||
|
})
|
||||||
|
response.assertStatus(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuse un password < 8 chars (422)', async ({ client }) => {
|
||||||
|
const response = await client.post('/api/v1/auth/signup').json({
|
||||||
|
email: 'short@spec.test',
|
||||||
|
password: 'abc',
|
||||||
|
fullName: 'Test',
|
||||||
|
})
|
||||||
|
response.assertStatus(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuse un email déjà pris (422)', async ({ client }) => {
|
||||||
|
await client.post('/api/v1/auth/signup').json({
|
||||||
|
email: 'twice@spec.test',
|
||||||
|
password: 'password123',
|
||||||
|
fullName: 'First',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await client.post('/api/v1/auth/signup').json({
|
||||||
|
email: 'twice@spec.test',
|
||||||
|
password: 'password123',
|
||||||
|
fullName: 'Second',
|
||||||
|
})
|
||||||
|
response.assertStatus(422)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.group('Auth — POST /auth/login', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('émet une AuthSession avec credentials valides', async ({ client, assert }) => {
|
||||||
|
const { user, plainPassword } = await createTestUser()
|
||||||
|
|
||||||
|
const response = await client.post('/api/v1/auth/login').json({
|
||||||
|
email: user.email,
|
||||||
|
password: plainPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(200)
|
||||||
|
const data = response.body().data
|
||||||
|
assert.equal(data.user.email, user.email)
|
||||||
|
assert.isString(data.accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejette un mauvais password (401)', async ({ client }) => {
|
||||||
|
const { user } = await createTestUser()
|
||||||
|
const response = await client.post('/api/v1/auth/login').json({
|
||||||
|
email: user.email,
|
||||||
|
password: 'wrong-password',
|
||||||
|
})
|
||||||
|
response.assertStatus(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejette un email inconnu (401)', async ({ client }) => {
|
||||||
|
const response = await client.post('/api/v1/auth/login').json({
|
||||||
|
email: 'ghost@spec.test',
|
||||||
|
password: 'password123',
|
||||||
|
})
|
||||||
|
response.assertStatus(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.group('Auth — bearer requis', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('GET /account/profile → 401 sans token', async ({ client }) => {
|
||||||
|
const response = await client.get('/api/v1/account/profile')
|
||||||
|
response.assertStatus(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /account/profile → 401 avec un token bidon', async ({ client }) => {
|
||||||
|
const response = await client
|
||||||
|
.get('/api/v1/account/profile')
|
||||||
|
.header('Authorization', 'Bearer invalid-token')
|
||||||
|
response.assertStatus(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /account/profile → 200 avec token valide', async ({ client, assert }) => {
|
||||||
|
const { user, accessToken } = await createTestUser()
|
||||||
|
const response = await client
|
||||||
|
.get('/api/v1/account/profile')
|
||||||
|
.header('Authorization', `Bearer ${accessToken}`)
|
||||||
|
|
||||||
|
response.assertStatus(200)
|
||||||
|
assert.equal(response.body().data.email, user.email)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.group('Auth — POST /account/logout', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('révoque le token courant (204)', async ({ client }) => {
|
||||||
|
const { accessToken } = await createTestUser()
|
||||||
|
|
||||||
|
const logout = await client
|
||||||
|
.post('/api/v1/account/logout')
|
||||||
|
.header('Authorization', `Bearer ${accessToken}`)
|
||||||
|
logout.assertStatus(204)
|
||||||
|
|
||||||
|
// Le token doit être révoqué : la requête suivante revient 401.
|
||||||
|
const after = await client
|
||||||
|
.get('/api/v1/account/profile')
|
||||||
|
.header('Authorization', `Bearer ${accessToken}`)
|
||||||
|
after.assertStatus(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.group('Auth — onboarding state', (group) => {
|
||||||
|
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||||
|
|
||||||
|
test('PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom', async ({
|
||||||
|
client,
|
||||||
|
assert,
|
||||||
|
}) => {
|
||||||
|
const { accessToken } = await createTestUser()
|
||||||
|
|
||||||
|
// 1er PATCH : pose onboardingCompletedAt
|
||||||
|
const r1 = await client
|
||||||
|
.patch('/api/v1/organizations/me')
|
||||||
|
.header('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.json({ name: 'Boulangerie Spec' })
|
||||||
|
r1.assertStatus(200)
|
||||||
|
assert.isNotNull(r1.body().data.onboardingCompletedAt)
|
||||||
|
|
||||||
|
// 2e PATCH : ne touche pas onboardingCompletedAt (déjà set)
|
||||||
|
const onboardedAt = r1.body().data.onboardingCompletedAt
|
||||||
|
const r2 = await client
|
||||||
|
.patch('/api/v1/organizations/me')
|
||||||
|
.header('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.json({ name: 'Boulangerie Spec 2' })
|
||||||
|
r2.assertStatus(200)
|
||||||
|
assert.equal(r2.body().data.onboardingCompletedAt, onboardedAt)
|
||||||
|
})
|
||||||
|
})
|
||||||
57
apps/api/tests/helpers/auth.ts
Normal file
57
apps/api/tests/helpers/auth.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import User from '#models/user'
|
||||||
|
import Organization from '#models/organization'
|
||||||
|
import { provisionDefaultPlans } from '#services/default_plans'
|
||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper : crée une organisation + un user + provisionne les 4 plans
|
||||||
|
* pré-fournis, dans une transaction. Retourne le user, l'org et un
|
||||||
|
* access token Bearer prêt à être passé en header.
|
||||||
|
*
|
||||||
|
* `counter` rend les emails uniques entre les tests d'une même suite —
|
||||||
|
* sans ça, les tests qui ne tombent pas dans une global tx risquent un
|
||||||
|
* email_taken.
|
||||||
|
*/
|
||||||
|
export async function createTestUser(overrides: {
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
fullName?: string
|
||||||
|
orgName?: string
|
||||||
|
} = {}) {
|
||||||
|
counter += 1
|
||||||
|
const email = overrides.email ?? `test-${counter}-${Date.now()}@rubis.test`
|
||||||
|
const password = overrides.password ?? 'password123'
|
||||||
|
const fullName = overrides.fullName ?? `Test User ${counter}`
|
||||||
|
const orgName = overrides.orgName ?? ''
|
||||||
|
|
||||||
|
const { user, org } = await db.transaction(async (trx) => {
|
||||||
|
const o = await Organization.create({ name: orgName }, { client: trx })
|
||||||
|
await provisionDefaultPlans(o.id, trx)
|
||||||
|
const u = await User.create({ email, password, fullName, organizationId: o.id }, { client: trx })
|
||||||
|
return { user: u, org: o }
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = await User.accessTokens.create(user)
|
||||||
|
// .release() consomme la valeur — on capture une seule fois.
|
||||||
|
const accessToken = token.value!.release()
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
org,
|
||||||
|
plainPassword: password,
|
||||||
|
accessToken,
|
||||||
|
bearer: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante : crée juste 2 users dans 2 orgs distinctes (pour les tests
|
||||||
|
* cross-org : user A ne peut pas voir/modifier les ressources de B).
|
||||||
|
*/
|
||||||
|
export async function createTwoOrgs() {
|
||||||
|
const a = await createTestUser({ orgName: 'Org A' })
|
||||||
|
const b = await createTestUser({ orgName: 'Org B' })
|
||||||
|
return { a, b }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user