mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 16:32:03 +00:00
Reprend la PR Dependabot #32. Rupture de la 11.0.0 : la pré-vérification CORS n'annonce plus par défaut que GET, HEAD et POST, un navigateur d'une autre origine aurait refusé les PUT, PATCH et DELETE de l'API. La liste d'avant est maintenant écrite dans app.ts, et un test vérifie la réponse à un OPTIONS d'une origine autorisée (il échoue sans la ligne). Option origin inchangée. Les ajouts de la 11.x (logLevel du préflight, cors par route dans config.cors) ne sont pas utilisés. 113 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
752 lines
23 KiB
TypeScript
752 lines
23 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import type { FastifyInstance } from 'fastify'
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { prismaMock } = vi.hoisted(() => ({
|
|
prismaMock: {
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
tree: {
|
|
findFirst: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
treeMembership: {
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
treeSettings: {
|
|
create: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
upsert: vi.fn(),
|
|
},
|
|
treeAccessPasswords: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
userTreeAccess: {
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
upsert: vi.fn(),
|
|
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
person: {
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
count: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
union: {
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
count: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
parentChildLink: {
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
count: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
mediaItem: {
|
|
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
annotation: {
|
|
findMany: vi.fn(),
|
|
},
|
|
contributionSession: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
|
count: vi.fn(),
|
|
},
|
|
contributionChange: {
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
treeVisit: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(),
|
|
$disconnect: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../src/lib/prisma.js', () => ({
|
|
prisma: prismaMock,
|
|
}))
|
|
|
|
let createApp: () => FastifyInstance
|
|
|
|
function applyTestEnv(): void {
|
|
process.env.NODE_ENV = 'test'
|
|
process.env.PORT = '4000'
|
|
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
|
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
|
process.env.JWT_EXPIRES_IN = '7d'
|
|
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
|
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
|
}
|
|
|
|
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
|
await app.ready()
|
|
return (app as any).jwt.sign(
|
|
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
|
{ expiresIn: '1h' },
|
|
)
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
applyTestEnv()
|
|
const module = await import('../src/app.js')
|
|
createApp = module.createApp
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
prismaMock.userTreeAccess.deleteMany?.mockResolvedValue({ count: 0 })
|
|
prismaMock.contributionSession.updateMany?.mockResolvedValue({ count: 1 })
|
|
prismaMock.mediaItem.aggregate?.mockResolvedValue({ _sum: { sizeBytes: 0 } })
|
|
|
|
prismaMock.user.findUnique.mockResolvedValue(null)
|
|
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' })
|
|
prismaMock.user.update.mockResolvedValue({ id: 'user-1' })
|
|
|
|
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
|
prismaMock.tree.findUnique.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
|
|
prismaMock.tree.create.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
|
|
prismaMock.tree.updateMany.mockResolvedValue({ count: 1 })
|
|
|
|
prismaMock.treeMembership.findMany.mockResolvedValue([])
|
|
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
|
treeId: 'tree-1',
|
|
userId: 'user-1',
|
|
role: 'owner',
|
|
tree: { deletedAt: null, rootPersonId: null },
|
|
})
|
|
|
|
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
|
treeId: 'tree-1',
|
|
visitorHash: 'hash',
|
|
contributorHash: 'hash',
|
|
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
|
tree: { deletedAt: null },
|
|
})
|
|
prismaMock.userTreeAccess.findMany.mockResolvedValue([])
|
|
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
|
|
prismaMock.userTreeAccess.upsert.mockResolvedValue({
|
|
id: 'share-1',
|
|
treeId: 'tree-1',
|
|
userId: 'user-1',
|
|
role: 'visitor',
|
|
})
|
|
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
|
|
prismaMock.treeSettings.findUnique.mockResolvedValue({
|
|
treeId: 'tree-1',
|
|
memberContributionPolicy: 'pending',
|
|
visitorPolicy: 'read_only',
|
|
contributorPolicy: 'pending',
|
|
})
|
|
prismaMock.treeSettings.create.mockResolvedValue({ treeId: 'tree-1' })
|
|
|
|
prismaMock.person.findMany.mockResolvedValue([])
|
|
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1' })
|
|
prismaMock.person.create.mockResolvedValue({ id: 'person-1' })
|
|
prismaMock.person.count.mockResolvedValue(0)
|
|
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.union.create.mockResolvedValue({ id: 'union-1' })
|
|
prismaMock.union.count.mockResolvedValue(0)
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.create.mockResolvedValue({ id: 'link-1' })
|
|
prismaMock.parentChildLink.count.mockResolvedValue(0)
|
|
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
|
prismaMock.mediaItem.count.mockResolvedValue(0)
|
|
prismaMock.annotation.findMany.mockResolvedValue([])
|
|
prismaMock.contributionSession.count.mockResolvedValue(0)
|
|
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
|
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
|
|
prismaMock.$disconnect.mockResolvedValue(undefined)
|
|
})
|
|
|
|
describe('Auth routes', () => {
|
|
it('registers a new user and returns a token', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { email: 'new@example.com', password: 'password123' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(201)
|
|
const body = response.json()
|
|
expect(body.token).toBeDefined()
|
|
expect(body.user.email).toBe('user@example.com')
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects register with existing email', async () => {
|
|
const app = createApp()
|
|
prismaMock.user.findUnique.mockResolvedValue({ id: 'existing', email: 'taken@example.com' })
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { email: 'taken@example.com', password: 'password123' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(409)
|
|
expect(response.json().error).toBe('email_already_exists')
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects register with invalid email', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { email: 'not-an-email', password: 'password123' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(400)
|
|
expect(response.json().error).toBe('invalid_payload')
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects register with short password', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { email: 'user@example.com', password: '123' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(400)
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects login with wrong credentials', async () => {
|
|
const app = createApp()
|
|
prismaMock.user.findUnique.mockResolvedValue({
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: '$2a$12$invalid_hash_that_wont_match_anything_00000000000000000',
|
|
})
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: 'user@example.com', password: 'wrong-password' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
expect(response.json().error).toBe('invalid_credentials')
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects login for non-existent user', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: 'nobody@example.com', password: 'password123' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
expect(response.json().error).toBe('invalid_credentials')
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects logout without token', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/logout',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Public routes', () => {
|
|
it('resolves tree by slug', async () => {
|
|
const app = createApp()
|
|
prismaMock.tree.findFirst.mockResolvedValue({
|
|
id: 'tree-1',
|
|
slug: 'famille-martin',
|
|
name: 'Famille Martin',
|
|
description: null,
|
|
})
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/arbre/famille-martin',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
const body = response.json()
|
|
expect(body.tree.slug).toBe('famille-martin')
|
|
expect(body.tree.name).toBe('Famille Martin')
|
|
expect(body.tree).not.toHaveProperty('ownerEmail')
|
|
expect(body.tree).not.toHaveProperty('ownerName')
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 404 for unknown slug', async () => {
|
|
const app = createApp()
|
|
prismaMock.tree.findFirst.mockResolvedValue(null)
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/arbre/inexistant',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(404)
|
|
expect(response.json().error).toBe('tree_not_found')
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 400 for invalid slug format', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/arbre/ab',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(400)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Graph routes', () => {
|
|
it('returns graph data for authenticated user', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
|
|
prismaMock.person.findMany.mockResolvedValue([
|
|
{
|
|
id: 'p-1',
|
|
treeId: 'tree-1',
|
|
firstName: 'Alice',
|
|
lastName: 'Martin',
|
|
birthDate: new Date('1990-01-15'),
|
|
deathDate: null,
|
|
sex: 'female',
|
|
notes: null,
|
|
createdBy: null,
|
|
updatedBy: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
deletedAt: null,
|
|
},
|
|
])
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
|
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
const body = response.json()
|
|
expect(body.treeId).toBe('tree-1')
|
|
expect(body.graph.persons).toHaveLength(1)
|
|
expect(body.graph.persons[0].firstName).toBe('Alice')
|
|
expect(body.graph.unions).toHaveLength(0)
|
|
expect(body.graph.filiations).toHaveLength(0)
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects graph access without auth', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects graph access for non-member user', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(403)
|
|
await app.close()
|
|
})
|
|
|
|
it('allows tree access token to fetch graph', async () => {
|
|
const app = createApp()
|
|
await app.ready()
|
|
|
|
const treeAccessToken = (app as any).jwt.sign({
|
|
kind: 'tree_access',
|
|
sub: 'tree:tree-1:visitor',
|
|
treeId: 'tree-1',
|
|
role: 'visitor',
|
|
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
|
})
|
|
|
|
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' })
|
|
prismaMock.person.findMany.mockResolvedValue([])
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
|
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
headers: { authorization: `Bearer ${treeAccessToken}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
expect(response.json().rootPersonId).toBe('p-1')
|
|
await app.close()
|
|
})
|
|
|
|
it('records a visit with the visitor first name when the graph opens', async () => {
|
|
const app = createApp()
|
|
await app.ready()
|
|
|
|
const treeAccessToken = (app as any).jwt.sign({
|
|
kind: 'tree_access',
|
|
sub: 'tree:tree-1:visitor',
|
|
treeId: 'tree-1',
|
|
role: 'visitor',
|
|
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
|
})
|
|
|
|
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' })
|
|
prismaMock.person.findMany.mockResolvedValue([])
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
|
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
|
prismaMock.treeVisit.findFirst.mockResolvedValue(null)
|
|
prismaMock.treeVisit.create.mockResolvedValue({})
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
headers: {
|
|
authorization: `Bearer ${treeAccessToken}`,
|
|
'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148',
|
|
'x-visitor-name': encodeURIComponent('Hélène'),
|
|
},
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
await vi.waitFor(() => expect(prismaMock.treeVisit.create).toHaveBeenCalled())
|
|
const data = prismaMock.treeVisit.create.mock.calls[0][0].data
|
|
expect(data).toMatchObject({ treeId: 'tree-1', userId: null, accessKind: 'share', visitorName: 'Hélène', device: 'phone' })
|
|
expect(data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
|
|
await app.close()
|
|
})
|
|
|
|
it('does not record a second visit for the same visitor within the window', async () => {
|
|
const app = createApp()
|
|
await app.ready()
|
|
|
|
const treeAccessToken = (app as any).jwt.sign({
|
|
kind: 'tree_access',
|
|
sub: 'tree:tree-1:visitor',
|
|
treeId: 'tree-1',
|
|
role: 'visitor',
|
|
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
|
})
|
|
|
|
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' })
|
|
prismaMock.person.findMany.mockResolvedValue([])
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
|
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
|
prismaMock.treeVisit.findFirst.mockResolvedValue({ id: 'v-1', visitorName: 'Hélène' })
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/graph',
|
|
headers: { authorization: `Bearer ${treeAccessToken}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
await vi.waitFor(() => expect(prismaMock.treeVisit.findFirst).toHaveBeenCalled())
|
|
expect(prismaMock.treeVisit.create).not.toHaveBeenCalled()
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Visit log', () => {
|
|
it('refuses the visit log to a non-admin member', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null } })
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/visits',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(403)
|
|
await app.close()
|
|
})
|
|
|
|
it('returns totals without the admin own visits, and recent visits', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'owner', tree: { deletedAt: null } })
|
|
prismaMock.treeVisit.deleteMany.mockResolvedValue({ count: 0 })
|
|
const now = Date.now()
|
|
prismaMock.treeVisit.findMany
|
|
.mockResolvedValueOnce([
|
|
{ visitorKey: 'a', createdAt: new Date(now - 1000) },
|
|
{ visitorKey: 'a', createdAt: new Date(now - 2 * 86400000) },
|
|
{ visitorKey: 'b', createdAt: new Date(now - 10 * 86400000) },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ id: 'v-2', createdAt: new Date(now - 1000), accessKind: 'share', visitorName: 'Hélène', device: 'phone', userId: null, user: null },
|
|
{ id: 'v-1', createdAt: new Date(now - 5000), accessKind: 'member', visitorName: null, device: 'desktop', userId: 'user-1', user: { email: 'user@example.com' } },
|
|
])
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees/tree-1/visits',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
const body = response.json()
|
|
expect(body.summary).toEqual({ last7: { visits: 2, visitors: 1 }, last30: { visits: 3, visitors: 2 } })
|
|
expect(body.recent[0]).toMatchObject({ name: 'Hélène', device: 'phone', isYou: false })
|
|
expect(body.recent[1]).toMatchObject({ email: 'user@example.com', isYou: true })
|
|
const totalsWhere = prismaMock.treeVisit.findMany.mock.calls[0][0].where
|
|
expect(totalsWhere.OR).toEqual([{ userId: null }, { userId: { not: 'user-1' } }])
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Health check', () => {
|
|
it('returns ok', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/health',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
expect(response.json()).toEqual({ ok: true })
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('CORS', () => {
|
|
it('annonce PUT, PATCH et DELETE dans la réponse de pré-vérification', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/trees/tree-1',
|
|
headers: { origin: 'http://localhost:5173', 'access-control-request-method': 'DELETE' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(204)
|
|
expect(response.headers['access-control-allow-origin']).toBe('http://localhost:5173')
|
|
expect(response.headers['access-control-allow-methods']).toBe('GET,HEAD,PUT,PATCH,POST,DELETE')
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Tree CRUD', () => {
|
|
it('creates a tree and returns it', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/trees',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
name: 'Mon Arbre',
|
|
slug: 'mon-arbre',
|
|
visitorPassword: 'visitor-pass-123',
|
|
contributorPassword: 'contributor-pass-123',
|
|
},
|
|
})
|
|
|
|
expect(response.statusCode).toBe(201)
|
|
expect(prismaMock.tree.create).toHaveBeenCalled()
|
|
const createCall = prismaMock.tree.create.mock.calls[0][0]
|
|
expect(createCall.data.memberships.create.role).toBe('owner')
|
|
expect(createCall.data.settings.create).toBeDefined()
|
|
expect(createCall.data.accessPasswords.create.visitorHash).toBeDefined()
|
|
await app.close()
|
|
})
|
|
|
|
it('requires auth to create a tree', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/trees',
|
|
payload: { name: 'Mon Arbre', slug: 'mon-arbre' },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
|
|
it('lists user trees', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
prismaMock.treeMembership.findMany.mockResolvedValue([
|
|
{
|
|
role: 'owner',
|
|
tree: { id: 'tree-1', slug: 'mon-arbre', name: 'Mon Arbre', description: null, rootPersonId: null, createdAt: new Date() },
|
|
},
|
|
])
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(response.statusCode).toBe(200)
|
|
const body = response.json()
|
|
expect(body.trees).toHaveLength(1)
|
|
expect(body.trees[0].name).toBe('Mon Arbre')
|
|
await app.close()
|
|
})
|
|
|
|
it('requires auth to list trees', async () => {
|
|
const app = createApp()
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/trees',
|
|
})
|
|
|
|
expect(response.statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('Relationship guardrails', () => {
|
|
it('rejects creating a union between ancestor and descendant', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
|
|
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
|
|
id: args.where.id,
|
|
}))
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([
|
|
{
|
|
id: 'link-1',
|
|
parentPersonId: 'person-parent',
|
|
childPersonId: 'person-child',
|
|
viaUnionId: null,
|
|
},
|
|
])
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/trees/tree-1/unions',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
partner1PersonId: 'person-parent',
|
|
partner2PersonId: 'person-child',
|
|
unionType: 'mariage',
|
|
},
|
|
})
|
|
|
|
expect(response.statusCode).toBe(400)
|
|
expect(response.json().error).toBe('forbidden_union_with_ancestor_descendant')
|
|
expect(prismaMock.union.create).not.toHaveBeenCalled()
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects creating a parent-child link that introduces a cycle', async () => {
|
|
const app = createApp()
|
|
const token = await createUserToken(app)
|
|
|
|
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
|
|
id: args.where.id,
|
|
}))
|
|
prismaMock.union.findMany.mockResolvedValue([])
|
|
prismaMock.parentChildLink.findMany.mockResolvedValue([
|
|
{
|
|
id: 'link-a',
|
|
parentPersonId: 'a',
|
|
childPersonId: 'b',
|
|
viaUnionId: null,
|
|
},
|
|
{
|
|
id: 'link-b',
|
|
parentPersonId: 'b',
|
|
childPersonId: 'c',
|
|
viaUnionId: null,
|
|
},
|
|
])
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/trees/tree-1/links',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
parentPersonId: 'c',
|
|
childPersonId: 'a',
|
|
parentageType: 'biologique',
|
|
},
|
|
})
|
|
|
|
expect(response.statusCode).toBe(400)
|
|
expect(response.json().error).toBe('parent_child_cycle_detected')
|
|
expect(prismaMock.parentChildLink.create).not.toHaveBeenCalled()
|
|
await app.close()
|
|
})
|
|
})
|