mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
4188 lines
158 KiB
TypeScript
4188 lines
158 KiB
TypeScript
import { ClientContract } from '@zenstackhq/orm';
|
|
import { SchemaDef } from '@zenstackhq/orm/schema';
|
|
import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools';
|
|
import { Decimal } from 'decimal.js';
|
|
import SuperJSON from 'superjson';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { RestApiHandler } from '../../src/api/rest';
|
|
|
|
const idDivider = '_';
|
|
|
|
describe('REST server tests', () => {
|
|
let client: ClientContract<SchemaDef>;
|
|
let handler: (any: any) => Promise<{ status: number; body: any }>;
|
|
|
|
describe('REST server tests - regular client', () => {
|
|
const schema = `
|
|
type Address {
|
|
city String
|
|
}
|
|
|
|
model User {
|
|
myId String @id @default(cuid())
|
|
createdAt DateTime @default (now())
|
|
updatedAt DateTime @updatedAt
|
|
email String @unique @email
|
|
posts Post[]
|
|
likes PostLike[]
|
|
profile Profile?
|
|
address Address? @json
|
|
someJson Json?
|
|
}
|
|
|
|
model Profile {
|
|
id Int @id @default(autoincrement())
|
|
gender String
|
|
user User @relation(fields: [userId], references: [myId])
|
|
userId String @unique
|
|
}
|
|
|
|
model Post {
|
|
id Int @id @default(autoincrement())
|
|
createdAt DateTime @default (now())
|
|
updatedAt DateTime @updatedAt
|
|
title String @length(1, 10)
|
|
author User? @relation(fields: [authorId], references: [myId])
|
|
authorId String?
|
|
published Boolean @default(false)
|
|
publishedAt DateTime?
|
|
viewCount Int @default(0)
|
|
comments Comment[]
|
|
likes PostLike[]
|
|
setting Setting?
|
|
}
|
|
|
|
model Comment {
|
|
id Int @id @default(autoincrement())
|
|
post Post @relation(fields: [postId], references: [id])
|
|
postId Int
|
|
content String
|
|
}
|
|
|
|
model Setting {
|
|
id Int @id @default(autoincrement())
|
|
boost Int
|
|
post Post @relation(fields: [postId], references: [id])
|
|
postId Int @unique
|
|
}
|
|
|
|
model PostLike {
|
|
postId Int
|
|
userId String
|
|
superLike Boolean
|
|
post Post @relation(fields: [postId], references: [id])
|
|
user User @relation(fields: [userId], references: [myId])
|
|
likeInfos PostLikeInfo[]
|
|
@@id([postId, userId])
|
|
}
|
|
|
|
model PostLikeInfo {
|
|
id Int @id @default(autoincrement())
|
|
text String
|
|
postId Int
|
|
userId String
|
|
postLike PostLike @relation(fields: [postId, userId], references: [postId, userId])
|
|
}
|
|
`;
|
|
|
|
beforeEach(async () => {
|
|
client = await createTestClient(schema);
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
describe('CRUD', () => {
|
|
describe('GET', () => {
|
|
it('invalid type, id, relationship', async () => {
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/foo',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/foo',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/foo',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it('returns an empty array when no item exists', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: [],
|
|
links: {
|
|
self: 'http://localhost/api/user',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns all items when there are some in the database', async () => {
|
|
// Create users first
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: { title: 'Post2' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user',
|
|
},
|
|
meta: {
|
|
total: 2,
|
|
},
|
|
data: [
|
|
{
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user1@abc.com' },
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 1 }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'User',
|
|
id: 'user2',
|
|
attributes: { email: 'user2@abc.com' },
|
|
links: {
|
|
self: 'http://localhost/api/user/user2',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user2/relationships/posts',
|
|
related: 'http://localhost/api/user/user2/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 2 }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('returns a single item when the ID is specified', async () => {
|
|
// Create a user first
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user1@abc.com' },
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 1 }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('fetch a related resource', async () => {
|
|
// Create a user first
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [
|
|
{
|
|
type: 'Post',
|
|
id: 1,
|
|
attributes: {
|
|
title: 'Post1',
|
|
authorId: 'user1',
|
|
published: false,
|
|
viewCount: 0,
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/post/1',
|
|
},
|
|
relationships: {
|
|
author: {
|
|
links: {
|
|
self: 'http://localhost/api/post/1/relationships/author',
|
|
related: 'http://localhost/api/post/1/author',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('returns an empty data array when loading empty related resources', async () => {
|
|
// Create a user first
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user1@abc.com' },
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('fetches a related resource with a compound ID', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
await client.postLike.create({
|
|
data: { postId: 1, userId: 'user1', superLike: true },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/post/1/relationships/likes',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/post/1/relationships/likes',
|
|
},
|
|
data: [{ type: 'PostLike', id: `1${idDivider}user1` }],
|
|
});
|
|
});
|
|
|
|
it('fetch a relationship', async () => {
|
|
// Create a user first
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 1 }],
|
|
});
|
|
});
|
|
|
|
it('returns 404 if the specified ID does not exist', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/nonexistentuser',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
expect(r.body).toEqual({
|
|
errors: [
|
|
{
|
|
code: 'not-found',
|
|
status: 404,
|
|
title: 'Resource not found',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('toplevel filtering', async () => {
|
|
const now = new Date();
|
|
const past = new Date(now.getTime() - 1);
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
address: { city: 'Seattle' },
|
|
someJson: 'foo',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: { id: 2, title: 'Post2', viewCount: 1, published: true, publishedAt: now },
|
|
},
|
|
},
|
|
});
|
|
|
|
// id filter
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[id]']: 'user2' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user2' });
|
|
|
|
// multi-id filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[id]']: 'user1,user2' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
// String filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email]']: 'user1@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user1' });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$contains]']: '1@abc' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user1' });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$contains]']: '1@bc' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$startsWith]']: 'user1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user1' });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$startsWith]']: 'ser1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$endsWith]']: '1@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user1' });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$endsWith]']: '1@abc' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$between]']: ',user1@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$between]']: 'user1@abc.com,' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$between]']: ',user2@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$between]']: 'user1@abc.com,user2@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
// Int filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$gt]']: '0' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$gte]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$lt]']: '0' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$lte]']: '0' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$between]']: '1,2' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$between]']: '2,1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount$between]']: '0,2' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
// DateTime filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[publishedAt$between]']: `${now.toISOString()},${now.toISOString()}` },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[publishedAt$between]']: `${past.toISOString()},${now.toISOString()}` },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[publishedAt$between]']: `${now.toISOString()},${past.toISOString()}` },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
// Boolean filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[published]']: 'true' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// deep to-one filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[author][email]']: 'user1@abc.com' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
|
|
// deep to-many filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[posts][published]']: 'true' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
|
|
// filter to empty
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[id]']: 'user3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
// to-many relation collection filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[posts]']: '2' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 'user2' });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[posts]']: '1,2,3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
// multi filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: {
|
|
['filter[author][email]']: 'user1@abc.com',
|
|
['filter[title]']: 'Post1',
|
|
},
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: {
|
|
['filter[author][email]']: 'user1@abc.com',
|
|
['filter[title]']: 'Post2',
|
|
},
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
// to-one relation filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[author]']: 'user1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
// relation filter with multiple values
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[author]']: 'user1,user2' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
|
|
// invalid filter field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[foo]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-filter',
|
|
title: 'Invalid filter',
|
|
},
|
|
],
|
|
});
|
|
|
|
// invalid filter value
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { ['filter[viewCount]']: 'a' },
|
|
client,
|
|
});
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-value',
|
|
title: 'Invalid value for type',
|
|
},
|
|
],
|
|
});
|
|
|
|
// invalid filter operation
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['filter[email$foo]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-filter',
|
|
title: 'Invalid filter',
|
|
},
|
|
],
|
|
});
|
|
|
|
// TODO: JSON filter
|
|
// // typedef equality filter
|
|
// r = await handler({
|
|
// method: 'get',
|
|
// path: '/user',
|
|
// query: { ['filter[address]']: JSON.stringify({ city: 'Seattle' }) },
|
|
// client,
|
|
// });
|
|
// expect(r.body.data).toHaveLength(1);
|
|
// r = await handler({
|
|
// method: 'get',
|
|
// path: '/user',
|
|
// query: { ['filter[address]']: JSON.stringify({ city: 'Tokyo' }) },
|
|
// client,
|
|
// });
|
|
// expect(r.body.data).toHaveLength(0);
|
|
|
|
// // plain json equality filter
|
|
// r = await handler({
|
|
// method: 'get',
|
|
// path: '/user',
|
|
// query: { ['filter[someJson]']: JSON.stringify('foo') },
|
|
// client,
|
|
// });
|
|
// expect(r.body.data).toHaveLength(1);
|
|
// r = await handler({
|
|
// method: 'get',
|
|
// path: '/user',
|
|
// query: { ['filter[someJson]']: JSON.stringify('bar') },
|
|
// client,
|
|
// });
|
|
// expect(r.body.data).toHaveLength(0);
|
|
|
|
// // invalid json
|
|
// r = await handler({
|
|
// method: 'get',
|
|
// path: '/user',
|
|
// query: { ['filter[someJson]']: '{ hello: world }' },
|
|
// client,
|
|
// });
|
|
// expect(r.body).toMatchObject({
|
|
// errors: [
|
|
// {
|
|
// status: 400,
|
|
// code: 'invalid-value',
|
|
// title: 'Invalid value for type',
|
|
// },
|
|
// ],
|
|
// });
|
|
});
|
|
|
|
it('related data filtering', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: { id: 2, title: 'Post2', viewCount: 1, published: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { ['filter[viewCount]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user2/posts',
|
|
query: { ['filter[viewCount]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
});
|
|
|
|
it('relationship filtering', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1' },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: { id: 2, title: 'Post2', viewCount: 1, published: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { ['filter[viewCount]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user2/relationships/posts',
|
|
query: { ['filter[viewCount]']: '1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(1);
|
|
});
|
|
|
|
it('toplevel sorting', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1', viewCount: 1, published: true },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: { id: 2, title: 'Post2', viewCount: 2, published: false },
|
|
},
|
|
},
|
|
});
|
|
|
|
// basic sorting
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
// basic sorting desc
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: '-viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// by relation id
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: '-author' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// by relation field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: '-author.email' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// multi-field sorting
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'published,viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'viewCount,published' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: '-viewCount,-published' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// invalid field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'foo' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-sort',
|
|
},
|
|
],
|
|
});
|
|
|
|
// sort with collection
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'comments' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-sort',
|
|
},
|
|
],
|
|
});
|
|
|
|
// sort with regular field in the middle
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post',
|
|
query: { sort: 'viewCount.foo' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-sort',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('related data sorting', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: [
|
|
{
|
|
id: 1,
|
|
title: 'Post1',
|
|
viewCount: 1,
|
|
published: true,
|
|
setting: { create: { boost: 1 } },
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Post2',
|
|
viewCount: 2,
|
|
published: false,
|
|
setting: { create: { boost: 2 } },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// asc
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { sort: 'viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
// desc
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { sort: '-viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// relation field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { sort: '-setting.boost' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
});
|
|
|
|
it('relationship sorting', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: [
|
|
{
|
|
id: 1,
|
|
title: 'Post1',
|
|
viewCount: 1,
|
|
published: true,
|
|
setting: { create: { boost: 1 } },
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Post2',
|
|
viewCount: 2,
|
|
published: false,
|
|
setting: { create: { boost: 2 } },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// asc
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { sort: 'viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 1 });
|
|
|
|
// desc
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { sort: '-viewCount' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
|
|
// relation field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { sort: '-setting.boost' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data[0]).toMatchObject({ id: 2 });
|
|
});
|
|
|
|
it('including', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } },
|
|
},
|
|
profile: {
|
|
create: { gender: 'male' },
|
|
},
|
|
},
|
|
});
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user2',
|
|
email: 'user2@abc.com',
|
|
posts: {
|
|
create: {
|
|
id: 2,
|
|
title: 'Post2',
|
|
viewCount: 1,
|
|
published: true,
|
|
comments: { create: { content: 'Comment2' } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// collection query include
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { include: 'posts' },
|
|
client,
|
|
});
|
|
expect(r.body.included).toHaveLength(2);
|
|
expect(r.body.included[0]).toMatchObject({
|
|
type: 'Post',
|
|
id: 1,
|
|
attributes: { title: 'Post1' },
|
|
});
|
|
|
|
// single query include
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1',
|
|
query: { include: 'posts' },
|
|
client,
|
|
});
|
|
expect(r.body.included).toHaveLength(1);
|
|
expect(r.body.included[0]).toMatchObject({
|
|
type: 'Post',
|
|
id: 1,
|
|
attributes: { title: 'Post1' },
|
|
});
|
|
|
|
// related query include
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { include: 'posts.comments' },
|
|
client,
|
|
});
|
|
expect(r.body.included).toHaveLength(1);
|
|
expect(r.body.included[0]).toMatchObject({
|
|
type: 'Comment',
|
|
attributes: { content: 'Comment1' },
|
|
});
|
|
|
|
// related query include with filter
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { include: 'posts.comments', ['filter[published]']: 'true' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
// deep include
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { include: 'posts.comments' },
|
|
client,
|
|
});
|
|
expect(r.body.included).toHaveLength(4);
|
|
expect(r.body.included[2]).toMatchObject({
|
|
type: 'Comment',
|
|
attributes: { content: 'Comment1' },
|
|
});
|
|
|
|
// multiple include
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { include: 'posts.comments,profile' },
|
|
client,
|
|
});
|
|
expect(r.body.included).toHaveLength(5);
|
|
const profile = r.body.included.find((item: any) => item.type === 'Profile');
|
|
expect(profile).toMatchObject({
|
|
type: 'Profile',
|
|
attributes: { gender: 'male' },
|
|
});
|
|
|
|
// invalid include
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { include: 'foo' },
|
|
client,
|
|
});
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [{ status: 400, code: 'unsupported-relationship' }],
|
|
});
|
|
});
|
|
|
|
it('toplevel pagination', async () => {
|
|
for (const i of Array(5).keys()) {
|
|
await client.user.create({
|
|
data: {
|
|
myId: `user${i}`,
|
|
email: `user${i}@abc.com`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// limit only
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[limit]']: '3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(3);
|
|
expect(r.body.meta.total).toBe(5);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=3',
|
|
prev: null,
|
|
next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3',
|
|
});
|
|
|
|
// limit & offset
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[limit]']: '3', ['page[offset]']: '3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
expect(r.body.meta.total).toBe(5);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=3',
|
|
prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3',
|
|
next: null,
|
|
});
|
|
|
|
// limit trimmed
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[limit]']: '10' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(5);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=0',
|
|
prev: null,
|
|
next: null,
|
|
});
|
|
|
|
// offset overflow
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[offset]']: '10' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(0);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=0',
|
|
prev: null,
|
|
next: null,
|
|
});
|
|
|
|
// minus offset
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[offset]']: '-1' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(5);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=0',
|
|
prev: null,
|
|
next: null,
|
|
});
|
|
|
|
// zero limit
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: { ['page[limit]']: '0' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(5);
|
|
expect(r.body.links).toMatchObject({
|
|
first: 'http://localhost/api/user?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user?page%5Boffset%5D=0',
|
|
prev: null,
|
|
next: null,
|
|
});
|
|
});
|
|
|
|
it('related data pagination', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: `user1`,
|
|
email: `user1@abc.com`,
|
|
posts: {
|
|
create: [...Array(10).keys()].map((i) => ({
|
|
id: i + 1,
|
|
title: `Post${i + 1}`,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
// default limiting
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(5);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/posts',
|
|
first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5',
|
|
prev: null,
|
|
next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5',
|
|
});
|
|
|
|
// explicit limiting
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { ['page[limit]']: '3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(3);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/posts',
|
|
first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9',
|
|
prev: null,
|
|
next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3',
|
|
});
|
|
|
|
// offset
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/posts',
|
|
query: { ['page[limit]']: '3', ['page[offset]']: '8' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/posts',
|
|
first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9',
|
|
prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3',
|
|
next: null,
|
|
});
|
|
});
|
|
|
|
it('relationship pagination', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: `user1`,
|
|
email: `user1@abc.com`,
|
|
posts: {
|
|
create: [...Array(10).keys()].map((i) => ({
|
|
id: i + 1,
|
|
title: `Post${i + 1}`,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
// default limiting
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(5);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5',
|
|
last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5',
|
|
prev: null,
|
|
next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5',
|
|
});
|
|
|
|
// explicit limiting
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { ['page[limit]']: '3' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(3);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9',
|
|
prev: null,
|
|
next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3',
|
|
});
|
|
|
|
// offset
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1/relationships/posts',
|
|
query: { ['page[limit]']: '3', ['page[offset]']: '8' },
|
|
client,
|
|
});
|
|
expect(r.body.data).toHaveLength(2);
|
|
expect(r.body.links).toMatchObject({
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3',
|
|
last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9',
|
|
prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3',
|
|
next: null,
|
|
});
|
|
});
|
|
|
|
describe('compound id', () => {
|
|
beforeEach(async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } },
|
|
});
|
|
await client.user.create({
|
|
data: { myId: 'user2', email: 'user2@abc.com' },
|
|
});
|
|
await client.postLike.create({
|
|
data: { userId: 'user2', postId: 1, superLike: false },
|
|
});
|
|
});
|
|
|
|
it('get all', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/postLike',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: [
|
|
{
|
|
type: 'PostLike',
|
|
id: `1${idDivider}user2`,
|
|
attributes: { userId: 'user2', postId: 1, superLike: false },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('get single', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: `/postLike/1${idDivider}user2`, // Order of ids is same as in the model @@id
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: {
|
|
type: 'PostLike',
|
|
id: `1${idDivider}user2`,
|
|
attributes: { userId: 'user2', postId: 1, superLike: false },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('get as relationship', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: `/post/1`,
|
|
query: { include: 'likes' },
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
data: {
|
|
relationships: {
|
|
likes: {
|
|
data: [{ type: 'PostLike', id: `1${idDivider}user2` }],
|
|
},
|
|
},
|
|
},
|
|
included: [
|
|
expect.objectContaining({
|
|
type: 'PostLike',
|
|
id: '1_user2',
|
|
attributes: {
|
|
postId: 1,
|
|
userId: 'user2',
|
|
superLike: false,
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/postLike/1_user2',
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST', () => {
|
|
it('creates an item without relation', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'User', attributes: { myId: 'user1', email: 'user1@abc.com' } },
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: { version: '1.1' },
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user1@abc.com' },
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [],
|
|
},
|
|
},
|
|
links: { self: 'http://localhost/api/user/user1' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('creates an item with date coercion', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/post',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: {
|
|
id: 1,
|
|
title: 'Post1',
|
|
published: true,
|
|
publishedAt: '2024-03-02T05:00:00.000Z',
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
|
|
it('creates an item with zod violation', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/post',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: {
|
|
id: 1,
|
|
title: 'a very very long long title',
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(422);
|
|
expect(r.body.errors[0].code).toBe('validation-error');
|
|
});
|
|
|
|
it('creates an item with collection relations', async () => {
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
await client.post.create({
|
|
data: { id: 2, title: 'Post2' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { myId: 'user1', email: 'user1@abc.com' },
|
|
relationships: {
|
|
posts: {
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: { version: '1.1' },
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: {
|
|
email: 'user1@abc.com',
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('creates an item with single relation', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/post',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'Post1' },
|
|
relationships: {
|
|
author: {
|
|
data: { type: 'User', id: 'user1' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/post/1',
|
|
},
|
|
data: {
|
|
type: 'Post',
|
|
id: 1,
|
|
attributes: {
|
|
title: 'Post1',
|
|
authorId: 'user1',
|
|
published: false,
|
|
viewCount: 0,
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/post/1',
|
|
},
|
|
relationships: {
|
|
author: {
|
|
links: {
|
|
self: 'http://localhost/api/post/1/relationships/author',
|
|
related: 'http://localhost/api/post/1/author',
|
|
},
|
|
data: { type: 'User', id: 'user1' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('create single relation disallowed', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'User', id: 'user1' },
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-verb',
|
|
title: 'The HTTP verb is not supported',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('create a collection of relations', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
await client.post.create({
|
|
data: { id: 2, title: 'Post2' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: {
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
},
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('create relation for nonexistent entity', async () => {
|
|
let r = await handler({
|
|
method: 'post',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: {
|
|
data: [{ type: 'Post', id: 1 }],
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com' },
|
|
});
|
|
|
|
r = await handler({
|
|
method: 'post',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: { data: [{ type: 'Post', id: 1 }] },
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it('create relation with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/postLike',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'postLike',
|
|
attributes: { userId: 'user1', postId: 1, superLike: false },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
|
|
it('compound id create single', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/postLike',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'postLike',
|
|
id: `1${idDivider}user1`,
|
|
attributes: { userId: 'user1', postId: 1, superLike: false },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
|
|
it('create an entity related to an entity with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } });
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/postLikeInfo',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'postLikeInfo',
|
|
attributes: { text: 'LikeInfo1' },
|
|
relationships: {
|
|
postLike: {
|
|
data: { type: 'postLike', id: `1${idDivider}user1` },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
|
|
it('upsert a new entity', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { myId: 'user1', email: 'user1@abc.com' },
|
|
},
|
|
meta: {
|
|
operation: 'upsert',
|
|
matchFields: ['myId'],
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: { version: '1.1' },
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user1@abc.com' },
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('upsert an existing entity', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { myId: 'user1', email: 'user2@abc.com' },
|
|
},
|
|
meta: {
|
|
operation: 'upsert',
|
|
matchFields: ['myId'],
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: { version: '1.1' },
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: { email: 'user2@abc.com' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('upsert fails if matchFields are not unique', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/profile',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'profile',
|
|
attributes: { gender: 'male' },
|
|
relationships: {
|
|
user: {
|
|
data: { type: 'User', id: 'user1' },
|
|
},
|
|
},
|
|
},
|
|
meta: {
|
|
operation: 'upsert',
|
|
matchFields: ['gender'],
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-payload',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('upsert works with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/postLike',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'postLike',
|
|
id: `1${idDivider}user1`,
|
|
attributes: { userId: 'user1', postId: 1, superLike: false },
|
|
},
|
|
meta: {
|
|
operation: 'upsert',
|
|
matchFields: ['userId', 'postId'],
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
});
|
|
|
|
describe('PUT', () => {
|
|
it('updates an item if it exists', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
},
|
|
});
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
await client.post.create({
|
|
data: { id: 2, title: 'Post2' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/user/user1',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { email: 'user2@abc.com' },
|
|
relationships: {
|
|
posts: {
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: {
|
|
email: 'user2@abc.com',
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [
|
|
{ type: 'Post', id: 1 },
|
|
{ type: 'Post', id: 2 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("returns an empty data list in relationships if it's empty", async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/user/user1',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { email: 'user2@abc.com' },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
attributes: {
|
|
email: 'user2@abc.com',
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/user/user1',
|
|
},
|
|
relationships: {
|
|
posts: {
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
related: 'http://localhost/api/user/user1/posts',
|
|
},
|
|
data: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns 404 if the user does not exist', async () => {
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/user/nonexistentuser',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { email: 'user2@abc.com' },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
expect(r.body).toEqual({
|
|
errors: [
|
|
expect.objectContaining({
|
|
code: 'not-found',
|
|
status: 404,
|
|
title: 'Resource not found',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('update an item with date coercion', async () => {
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/post/1',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: {
|
|
published: true,
|
|
publishedAt: '2024-03-02T05:00:00.000Z',
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
});
|
|
|
|
it('update an item with zod violation', async () => {
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/post/1',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: {
|
|
publishedAt: '2024-13-01',
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(422);
|
|
expect(r.body.errors[0].code).toBe('validation-error');
|
|
});
|
|
|
|
it('update item with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } });
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: `/postLike/1${idDivider}user1`,
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'PostLike',
|
|
attributes: { superLike: true },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
});
|
|
|
|
it('update the id of an item with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
await client.post.create({ data: { id: 2, title: 'Post2' } });
|
|
await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } });
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: `/postLike/1${idDivider}user1`,
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'PostLike',
|
|
relationships: {
|
|
post: { data: { type: 'Post', id: 2 } },
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.id).toBe(`2${idDivider}user1`);
|
|
});
|
|
|
|
it('update a single relation', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'patch',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: {
|
|
version: '1.1',
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/post/1/relationships/author',
|
|
},
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('remove a single relation', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'patch',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
requestBody: { data: null },
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/post/1/relationships/author',
|
|
},
|
|
data: null,
|
|
});
|
|
});
|
|
|
|
it('update a collection of relations', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } },
|
|
});
|
|
await client.post.create({
|
|
data: { id: 2, title: 'Post2' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'patch',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: {
|
|
data: [{ type: 'Post', id: 2 }],
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 2 }],
|
|
});
|
|
});
|
|
|
|
it('update a collection of relations with compound id', async () => {
|
|
await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
|
|
await client.post.create({ data: { id: 1, title: 'Post1' } });
|
|
await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } });
|
|
|
|
const r = await handler({
|
|
method: 'patch',
|
|
path: '/post/1/relationships/likes',
|
|
query: {},
|
|
requestBody: {
|
|
data: [{ type: 'PostLike', id: `1${idDivider}user1`, attributes: { superLike: true } }],
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
});
|
|
|
|
it('update a collection of relations to empty', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'patch',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: { data: [] },
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
},
|
|
data: [],
|
|
});
|
|
});
|
|
|
|
it('update relation for nonexistent entity', async () => {
|
|
let r = await handler({
|
|
method: 'patch',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Post1' },
|
|
});
|
|
|
|
r = await handler({
|
|
method: 'patch',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
id: 'user1',
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE', () => {
|
|
it('deletes an item if it exists', async () => {
|
|
// Create a user first
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: '/user/user1',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ meta: {} });
|
|
});
|
|
|
|
it('deletes an item with compound id', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } },
|
|
});
|
|
await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } });
|
|
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: `/postLike/1${idDivider}user1`,
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ meta: {} });
|
|
});
|
|
|
|
it('returns 404 if the user does not exist', async () => {
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: '/user/nonexistentuser',
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(404);
|
|
expect(r.body).toEqual({
|
|
errors: [
|
|
expect.objectContaining({
|
|
code: 'not-found',
|
|
status: 404,
|
|
title: 'Resource not found',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('delete single relation disallowed', async () => {
|
|
await client.user.create({
|
|
data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: '/post/1/relationships/author',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-verb',
|
|
title: 'The HTTP verb is not supported',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('delete a collection of relations', async () => {
|
|
await client.user.create({
|
|
data: {
|
|
myId: 'user1',
|
|
email: 'user1@abc.com',
|
|
posts: {
|
|
create: [
|
|
{ id: 1, title: 'Post1' },
|
|
{ id: 2, title: 'Post2' },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: {
|
|
data: [{ type: 'Post', id: 1 }],
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({
|
|
jsonapi: {
|
|
version: '1.1',
|
|
},
|
|
links: {
|
|
self: 'http://localhost/api/user/user1/relationships/posts',
|
|
},
|
|
data: [{ type: 'Post', id: 2 }],
|
|
});
|
|
});
|
|
|
|
it('delete relations for nonexistent entity', async () => {
|
|
const r = await handler({
|
|
method: 'delete',
|
|
path: '/user/user1/relationships/posts',
|
|
query: {},
|
|
requestBody: {
|
|
data: [{ type: 'Post', id: 1 }],
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('validation error', () => {
|
|
it('creates an item without relation', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'User', attributes: { myId: 'user1', email: 'user1.com' } },
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(422);
|
|
expect(r.body.errors[0].code).toBe('validation-error');
|
|
expect(r.body.errors[0].detail).toContain('Invalid email');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - access policy', () => {
|
|
const schema = `
|
|
model Foo {
|
|
id Int @id
|
|
value Int
|
|
|
|
@@allow('create,read', true)
|
|
@@allow('update', value > 0)
|
|
}
|
|
|
|
model Bar {
|
|
id Int @id
|
|
value Int
|
|
|
|
@@allow('create', true)
|
|
}
|
|
`;
|
|
|
|
beforeEach(async () => {
|
|
client = await createPolicyTestClient(schema);
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('update policy rejection test', async () => {
|
|
let r = await handler({
|
|
method: 'post',
|
|
path: '/foo',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'foo', attributes: { id: 1, value: 0 } },
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(201);
|
|
|
|
r = await handler({
|
|
method: 'put',
|
|
path: '/foo/1',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'foo', attributes: { value: 1 } },
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
expect(r.body.errors[0].code).toBe('not-found');
|
|
});
|
|
|
|
it('read-back policy rejection test', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/bar',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'bar', attributes: { id: 1, value: 0 } },
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(403);
|
|
expect(r.body.errors[0].reason).toBe('cannot-read-back');
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - NextAuth project regression', () => {
|
|
const schema = `
|
|
model Post {
|
|
id String @id @default(cuid())
|
|
title String
|
|
content String
|
|
|
|
// full access for all
|
|
@@allow('all', true)
|
|
}
|
|
|
|
model Account {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
type String
|
|
provider String
|
|
providerAccountId String
|
|
refresh_token String?
|
|
access_token String?
|
|
expires_at Int?
|
|
token_type String?
|
|
scope String?
|
|
id_token String?
|
|
session_state String?
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([provider, providerAccountId])
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
sessionToken String @unique
|
|
userId String
|
|
expires DateTime
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
name String?
|
|
email String @email @unique
|
|
emailVerified DateTime?
|
|
image String?
|
|
accounts Account[]
|
|
sessions Session[]
|
|
|
|
@@allow('create,read', true)
|
|
@@allow('delete,update', auth() != null)
|
|
}
|
|
|
|
model VerificationToken {
|
|
identifier String
|
|
token String @unique
|
|
expires DateTime
|
|
|
|
@@unique([identifier, token])
|
|
}
|
|
`;
|
|
|
|
beforeEach(async () => {
|
|
client = await createPolicyTestClient(schema);
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('crud test', async () => {
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(0);
|
|
|
|
r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'User', attributes: { email: 'user1@abc.com' } },
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(201);
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(1);
|
|
|
|
r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: { type: 'User', attributes: { email: 'user1@abc.com' } },
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(400);
|
|
expect(r.body.errors[0].code).toBe('query-error');
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - field type coverage', () => {
|
|
const schema = `
|
|
model Foo {
|
|
id Int @id
|
|
string String
|
|
int Int
|
|
bigInt BigInt
|
|
date DateTime
|
|
float Float
|
|
decimal Decimal
|
|
boolean Boolean
|
|
bytes Bytes
|
|
bars Bar[]
|
|
}
|
|
|
|
model Bar {
|
|
id Int @id
|
|
bytes Bytes
|
|
foo Foo? @relation(fields: [fooId], references: [id])
|
|
fooId Int? @unique
|
|
}
|
|
`;
|
|
|
|
it('field types', async () => {
|
|
const client = await createTestClient(schema, { provider: 'postgresql' });
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
|
|
await client.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } });
|
|
|
|
const decimalValue1 = new Decimal('0.046875');
|
|
const decimalValue2 = new Decimal('0.0146875');
|
|
|
|
const createAttrs = {
|
|
string: 'string',
|
|
int: 123,
|
|
bigInt: BigInt(534543543534),
|
|
date: new Date(),
|
|
float: 1.23,
|
|
decimal: decimalValue1,
|
|
boolean: true,
|
|
bytes: new Uint8Array([1, 2, 3, 4]),
|
|
};
|
|
|
|
const { json: createPayload, meta: createMeta } = SuperJSON.serialize({
|
|
data: {
|
|
type: 'foo',
|
|
attributes: { id: 1, ...createAttrs },
|
|
relationships: {
|
|
bars: {
|
|
data: [{ type: 'bar', id: 1 }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let r = await handler({
|
|
method: 'post',
|
|
path: '/foo',
|
|
query: {},
|
|
requestBody: {
|
|
...(createPayload as any),
|
|
meta: {
|
|
serialization: createMeta,
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(201);
|
|
// result is serializable
|
|
expect(JSON.stringify(r.body)).toBeTruthy();
|
|
let serializationMeta = r.body.meta.serialization;
|
|
expect(serializationMeta).toBeTruthy();
|
|
let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta });
|
|
let data = deserialized.data.attributes;
|
|
expect(typeof data.bigInt).toBe('bigint');
|
|
expect(data.bytes).toBeInstanceOf(Uint8Array);
|
|
expect(data.date instanceof Date).toBeTruthy();
|
|
expect(Decimal.isDecimal(data.decimal)).toBeTruthy();
|
|
|
|
const updateAttrs = {
|
|
bigInt: BigInt(1534543543534),
|
|
date: new Date(),
|
|
decimal: decimalValue2,
|
|
bytes: new Uint8Array([5, 2, 3, 4]),
|
|
};
|
|
const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({
|
|
data: {
|
|
type: 'foo',
|
|
attributes: updateAttrs,
|
|
},
|
|
});
|
|
|
|
r = await handler({
|
|
method: 'put',
|
|
path: '/foo/1',
|
|
query: {},
|
|
requestBody: {
|
|
...(updatePayload as any),
|
|
meta: {
|
|
serialization: updateMeta,
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
// result is serializable
|
|
expect(JSON.stringify(r.body)).toBeTruthy();
|
|
|
|
serializationMeta = r.body.meta.serialization;
|
|
expect(serializationMeta).toBeTruthy();
|
|
deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta });
|
|
data = deserialized.data.attributes;
|
|
expect(data.bigInt).toEqual(updateAttrs.bigInt);
|
|
expect(data.date).toEqual(updateAttrs.date);
|
|
expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy();
|
|
expect(data.bytes.toString()).toEqual(updateAttrs.bytes.toString());
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/foo/1',
|
|
query: {},
|
|
client,
|
|
});
|
|
// result is serializable
|
|
expect(JSON.stringify(r.body)).toBeTruthy();
|
|
serializationMeta = r.body.meta.serialization;
|
|
expect(serializationMeta).toBeTruthy();
|
|
deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta });
|
|
data = deserialized.data.attributes;
|
|
expect(typeof data.bigInt).toBe('bigint');
|
|
expect(data.bytes).toBeInstanceOf(Uint8Array);
|
|
expect(data.date instanceof Date).toBeTruthy();
|
|
expect(Decimal.isDecimal(data.decimal)).toBeTruthy();
|
|
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/foo',
|
|
query: { include: 'bars' },
|
|
client,
|
|
});
|
|
// result is serializable
|
|
expect(JSON.stringify(r.body)).toBeTruthy();
|
|
serializationMeta = r.body.meta.serialization;
|
|
expect(serializationMeta).toBeTruthy();
|
|
deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta });
|
|
const included = deserialized.included[0];
|
|
expect(included.attributes.bytes).toBeInstanceOf(Uint8Array);
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - compound id with custom separator', () => {
|
|
const schema = `
|
|
enum Role {
|
|
COMMON_USER
|
|
ADMIN_USER
|
|
}
|
|
|
|
model User {
|
|
email String
|
|
role Role
|
|
enabled Boolean @default(true)
|
|
|
|
@@id([email, role])
|
|
}
|
|
`;
|
|
const idDivider = ':';
|
|
|
|
beforeEach(async () => {
|
|
client = await createTestClient(schema);
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
idDivider,
|
|
urlSegmentCharset: 'a-zA-Z0-9-_~ %@.:',
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('POST', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { email: 'user1@abc.com', role: 'COMMON_USER' },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(201);
|
|
});
|
|
|
|
it('GET', async () => {
|
|
await client.user.create({
|
|
data: { email: 'user1@abc.com', role: 'COMMON_USER' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(1);
|
|
});
|
|
|
|
it('GET single', async () => {
|
|
await client.user.create({
|
|
data: { email: 'user1@abc.com', role: 'COMMON_USER' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/user/user1@abc.com:COMMON_USER',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.attributes.email).toBe('user1@abc.com');
|
|
});
|
|
|
|
it('PUT', async () => {
|
|
await client.user.create({
|
|
data: { email: 'user1@abc.com', role: 'COMMON_USER' },
|
|
});
|
|
|
|
const r = await handler({
|
|
method: 'put',
|
|
path: '/user/user1@abc.com:COMMON_USER',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'User',
|
|
attributes: { enabled: false },
|
|
},
|
|
},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.attributes.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - model name mapping', () => {
|
|
const schema = `
|
|
model User {
|
|
id String @id @default(cuid())
|
|
name String
|
|
posts Post[]
|
|
}
|
|
|
|
model Post {
|
|
id String @id @default(cuid())
|
|
title String
|
|
author User? @relation(fields: [authorId], references: [id])
|
|
authorId String?
|
|
}
|
|
`;
|
|
beforeEach(async () => {
|
|
client = await createTestClient(schema);
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
modelNameMapping: {
|
|
User: 'myUser',
|
|
},
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('works with name mapping', async () => {
|
|
// using original model name
|
|
await expect(
|
|
handler({
|
|
method: 'post',
|
|
path: '/user',
|
|
query: {},
|
|
requestBody: { data: { type: 'User', attributes: { id: '1', name: 'User1' } } },
|
|
client,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 400,
|
|
});
|
|
|
|
// using mapped model name
|
|
await expect(
|
|
handler({
|
|
method: 'post',
|
|
path: '/myUser',
|
|
query: {},
|
|
requestBody: { data: { type: 'User', attributes: { id: '1', name: 'User1' } } },
|
|
client,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 201,
|
|
body: {
|
|
links: { self: 'http://localhost/api/myUser/1' },
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
handler({
|
|
method: 'get',
|
|
path: '/myUser/1',
|
|
query: {},
|
|
client,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 200,
|
|
body: {
|
|
links: { self: 'http://localhost/api/myUser/1' },
|
|
},
|
|
});
|
|
|
|
// works with unmapped model name
|
|
await expect(
|
|
handler({
|
|
method: 'post',
|
|
path: '/post',
|
|
query: {},
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { id: '1', title: 'Post1' },
|
|
relationships: {
|
|
author: { data: { type: 'User', id: '1' } },
|
|
},
|
|
},
|
|
},
|
|
client,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: 201,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - external id mapping', () => {
|
|
const schema = `
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
source String
|
|
posts Post[]
|
|
|
|
@@unique([name, source])
|
|
}
|
|
|
|
model Post {
|
|
id Int @id @default(autoincrement())
|
|
title String
|
|
short_title String @unique()
|
|
author User? @relation(fields: [authorId], references: [id])
|
|
authorId Int?
|
|
}
|
|
`;
|
|
beforeEach(async () => {
|
|
client = await createTestClient(schema);
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
externalIdMapping: {
|
|
User: 'name_source',
|
|
Post: 'short_title',
|
|
},
|
|
});
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('works with id mapping', async () => {
|
|
await client.user.create({
|
|
data: { id: 1, name: 'User1', source: 'a' },
|
|
});
|
|
|
|
// user is no longer exposed using the `id` field
|
|
let r = await handler({
|
|
method: 'get',
|
|
path: '/user/1',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(422);
|
|
expect(r.body.errors[0].code).toBe('validation-error');
|
|
|
|
// user is exposed using the fields from the `name__source` multi-column unique index
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/user/User1_a',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.attributes.source).toBe('a');
|
|
expect(r.body.data.attributes.name).toBe('User1');
|
|
|
|
await client.post.create({
|
|
data: { id: 1, title: 'Title1', short_title: 'post-title-1', authorId: 1 },
|
|
});
|
|
|
|
// post is exposed using the `id` field
|
|
r = await handler({
|
|
method: 'get',
|
|
path: '/post/post-title-1',
|
|
query: { include: 'author' },
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.attributes.title).toBe('Title1');
|
|
// Verify author relationship contains the external ID
|
|
expect(r.body.data.relationships.author.data).toMatchObject({
|
|
type: 'User',
|
|
id: 'User1_a',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('REST server tests - procedures', () => {
|
|
const schema = `
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
}
|
|
|
|
enum Role {
|
|
ADMIN
|
|
USER
|
|
}
|
|
|
|
type Overview {
|
|
total Int
|
|
}
|
|
|
|
procedure echoDecimal(x: Decimal): Decimal
|
|
procedure greet(name: String?): String
|
|
procedure echoInt(x: Int): Int
|
|
procedure opt2(a: Int?, b: Int?): Int
|
|
procedure sumIds(ids: Int[]): Int
|
|
procedure echoRole(r: Role): Role
|
|
procedure echoOverview(o: Overview): Overview
|
|
|
|
mutation procedure sum(a: Int, b: Int): Int
|
|
`;
|
|
|
|
beforeEach(async () => {
|
|
interface ProcCtx<TArgs extends object> {
|
|
client: ClientContract<SchemaDef>;
|
|
args: TArgs;
|
|
}
|
|
|
|
interface ProcCtxOptionalArgs<TArgs extends object> {
|
|
client: ClientContract<SchemaDef>;
|
|
args?: TArgs;
|
|
}
|
|
|
|
type Role = 'ADMIN' | 'USER';
|
|
|
|
interface Overview {
|
|
total: number;
|
|
}
|
|
|
|
interface EchoDecimalArgs {
|
|
x: Decimal;
|
|
}
|
|
|
|
interface GreetArgs {
|
|
name?: string | null;
|
|
}
|
|
|
|
interface EchoIntArgs {
|
|
x: number;
|
|
}
|
|
|
|
interface Opt2Args {
|
|
a?: number | null;
|
|
b?: number | null;
|
|
}
|
|
|
|
interface SumIdsArgs {
|
|
ids: number[];
|
|
}
|
|
|
|
interface EchoRoleArgs {
|
|
r: Role;
|
|
}
|
|
|
|
interface EchoOverviewArgs {
|
|
o: Overview;
|
|
}
|
|
|
|
interface SumArgs {
|
|
a: number;
|
|
b: number;
|
|
}
|
|
|
|
interface Procedures {
|
|
echoDecimal: (ctx: ProcCtx<EchoDecimalArgs>) => Promise<Decimal>;
|
|
greet: (ctx: ProcCtxOptionalArgs<GreetArgs>) => Promise<string>;
|
|
echoInt: (ctx: ProcCtx<EchoIntArgs>) => Promise<number>;
|
|
opt2: (ctx: ProcCtxOptionalArgs<Opt2Args>) => Promise<number>;
|
|
sumIds: (ctx: ProcCtx<SumIdsArgs>) => Promise<number>;
|
|
echoRole: (ctx: ProcCtx<EchoRoleArgs>) => Promise<Role>;
|
|
echoOverview: (ctx: ProcCtx<EchoOverviewArgs>) => Promise<Overview>;
|
|
sum: (ctx: ProcCtx<SumArgs>) => Promise<number>;
|
|
}
|
|
|
|
client = await createTestClient(schema as unknown as SchemaDef, {
|
|
procedures: {
|
|
echoDecimal: async ({ args }: ProcCtx<EchoDecimalArgs>) => args.x,
|
|
greet: async ({ args }: ProcCtxOptionalArgs<GreetArgs>) => {
|
|
const name = args?.name as string | undefined;
|
|
return `hi ${name ?? 'anon'}`;
|
|
},
|
|
echoInt: async ({ args }: ProcCtx<EchoIntArgs>) => args.x,
|
|
opt2: async ({ args }: ProcCtxOptionalArgs<Opt2Args>) => {
|
|
const a = args?.a as number | undefined;
|
|
const b = args?.b as number | undefined;
|
|
return (a ?? 0) + (b ?? 0);
|
|
},
|
|
sumIds: async ({ args }: ProcCtx<SumIdsArgs>) =>
|
|
(args.ids as number[]).reduce((acc, x) => acc + x, 0),
|
|
echoRole: async ({ args }: ProcCtx<EchoRoleArgs>) => args.r,
|
|
echoOverview: async ({ args }: ProcCtx<EchoOverviewArgs>) => args.o,
|
|
sum: async ({ args }: ProcCtx<SumArgs>) => args.a + args.b,
|
|
} as Procedures,
|
|
});
|
|
|
|
const _handler = new RestApiHandler({
|
|
schema: client.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
pageSize: 5,
|
|
});
|
|
|
|
handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('supports GET query procedures with q/meta (SuperJSON)', async () => {
|
|
const { json, meta } = SuperJSON.serialize({ args: { x: new Decimal('1.23') } });
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/echoDecimal',
|
|
query: { ...(json as object), meta: { serialization: meta } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: '1.23' });
|
|
});
|
|
|
|
it('supports GET procedures without args when param is optional', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/greet',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 'hi anon' });
|
|
});
|
|
|
|
it('errors for missing required single-param arg', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/echoInt',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-payload',
|
|
detail: 'missing procedure arguments',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('supports GET procedures without args when all params are optional', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/opt2',
|
|
query: {},
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 0 });
|
|
});
|
|
|
|
it('supports array-typed single param via envelope args', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/sumIds',
|
|
query: { args: { ids: [1, 2, 3] } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 6 });
|
|
});
|
|
|
|
it('supports enum-typed params with validation', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/echoRole',
|
|
query: { args: { r: 'ADMIN' } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 'ADMIN' });
|
|
});
|
|
|
|
it('supports typedef params (object payload)', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/echoOverview',
|
|
query: { args: { o: { total: 123 } } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: { total: 123 } });
|
|
});
|
|
|
|
it('errors for wrong type input', async () => {
|
|
const r = await handler({
|
|
method: 'get',
|
|
path: '/$procs/echoInt',
|
|
query: { args: { x: 'not-an-int' } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(422);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 422,
|
|
code: 'validation-error',
|
|
},
|
|
],
|
|
});
|
|
expect(r.body.errors?.[0]?.detail).toMatch(/invalid input/i);
|
|
});
|
|
|
|
it('supports POST mutation procedures with args passed via q/meta', async () => {
|
|
const { json, meta } = SuperJSON.serialize({ args: { a: 1, b: 2 } });
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/$procs/sum',
|
|
requestBody: { ...(json as object), meta: { serialization: meta } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 3 });
|
|
});
|
|
|
|
it('errors for invalid `args` payload type', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/$procs/sum',
|
|
requestBody: { args: [1, 2, 3] } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-payload',
|
|
},
|
|
],
|
|
});
|
|
expect(r.body.errors?.[0]?.detail).toMatch(/args/i);
|
|
});
|
|
|
|
it('errors for unknown argument keys (object mapping)', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/$procs/sum',
|
|
requestBody: { args: { a: 1, b: 2, c: 3 } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
expect(r.body).toMatchObject({
|
|
errors: [
|
|
{
|
|
status: 400,
|
|
code: 'invalid-payload',
|
|
},
|
|
],
|
|
});
|
|
expect(r.body.errors?.[0]?.detail).toMatch(/unknown procedure argument/i);
|
|
});
|
|
|
|
it('supports /$procs path', async () => {
|
|
const r = await handler({
|
|
method: 'post',
|
|
path: '/$procs/sum',
|
|
requestBody: { args: { a: 1, b: 2 } } as any,
|
|
client,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toMatchObject({ data: 3 });
|
|
});
|
|
});
|
|
|
|
describe('Nested routes', () => {
|
|
let nestedClient: ClientContract<SchemaDef>;
|
|
let nestedHandler: (any: any) => Promise<{ status: number; body: any }>;
|
|
|
|
const nestedSchema = `
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
posts Post[]
|
|
}
|
|
|
|
model Post {
|
|
id Int @id @default(autoincrement())
|
|
title String
|
|
author User @relation(fields: [authorId], references: [id])
|
|
authorId String
|
|
}
|
|
`;
|
|
|
|
beforeEach(async () => {
|
|
nestedClient = await createTestClient(nestedSchema);
|
|
const api = new RestApiHandler({
|
|
schema: nestedClient.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
nestedRoutes: true,
|
|
});
|
|
nestedHandler = (args) => api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
});
|
|
|
|
it('scopes nested collection reads to parent', async () => {
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: {
|
|
create: [{ title: 'u1-post-1' }, { title: 'u1-post-2' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u2',
|
|
email: 'u2@test.com',
|
|
posts: {
|
|
create: [{ title: 'u2-post-1' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const r = await nestedHandler({
|
|
method: 'get',
|
|
path: '/user/u1/posts',
|
|
client: nestedClient,
|
|
});
|
|
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toHaveLength(2);
|
|
expect(r.body.data.map((item: any) => item.attributes.title).sort()).toEqual(['u1-post-1', 'u1-post-2']);
|
|
});
|
|
|
|
it('returns 404 for nested collection read when parent does not exist', async () => {
|
|
const r = await nestedHandler({
|
|
method: 'get',
|
|
path: '/user/nonexistent/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it('scopes nested single reads to parent', async () => {
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
},
|
|
});
|
|
|
|
const user2 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u2',
|
|
email: 'u2@test.com',
|
|
posts: {
|
|
create: [{ title: 'u2-post-1' }],
|
|
},
|
|
},
|
|
include: {
|
|
posts: true,
|
|
},
|
|
});
|
|
|
|
const postId = user2.posts[0]!.id;
|
|
|
|
const denied = await nestedHandler({
|
|
method: 'get',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(denied.status).toBe(404);
|
|
|
|
const allowed = await nestedHandler({
|
|
method: 'get',
|
|
path: `/user/u2/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(allowed.status).toBe(200);
|
|
expect(allowed.body.data.attributes.title).toBe('u2-post-1');
|
|
});
|
|
|
|
it('returns 404 for nested single read when parent does not exist', async () => {
|
|
const r = await nestedHandler({
|
|
method: 'get',
|
|
path: '/user/nonexistent/posts/1',
|
|
client: nestedClient,
|
|
});
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for nested create when parent does not exist', async () => {
|
|
const r = await nestedHandler({
|
|
method: 'post',
|
|
path: '/user/nonexistent/posts',
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'orphan' },
|
|
},
|
|
},
|
|
});
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it('binds nested creates to parent relation automatically', async () => {
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
},
|
|
});
|
|
|
|
const created = await nestedHandler({
|
|
method: 'post',
|
|
path: '/user/u1/posts',
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: {
|
|
title: 'nested-created',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(created.status).toBe(201);
|
|
|
|
const dbPost = await nestedClient.post.findFirst({
|
|
where: {
|
|
title: 'nested-created',
|
|
},
|
|
});
|
|
|
|
expect(dbPost?.authorId).toBe('u1');
|
|
});
|
|
|
|
it('rejects nested create when payload specifies the forced parent relation', async () => {
|
|
await nestedClient.user.create({
|
|
data: { id: 'u1', email: 'u1@test.com' },
|
|
});
|
|
await nestedClient.user.create({
|
|
data: { id: 'u2', email: 'u2@test.com' },
|
|
});
|
|
|
|
const r = await nestedHandler({
|
|
method: 'post',
|
|
path: '/user/u1/posts',
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'conflict' },
|
|
relationships: {
|
|
author: { data: { type: 'User', id: 'u2' } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
it('rejects nested create when attributes contain scalar FK for the forced parent relation', async () => {
|
|
await nestedClient.user.create({
|
|
data: { id: 'u1', email: 'u1@test.com' },
|
|
});
|
|
await nestedClient.user.create({
|
|
data: { id: 'u2', email: 'u2@test.com' },
|
|
});
|
|
|
|
const r = await nestedHandler({
|
|
method: 'post',
|
|
path: '/user/u1/posts',
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'conflict', authorId: 'u2' },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
it('scopes nested collection reads with filter and pagination', async () => {
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: {
|
|
create: [{ title: 'alpha' }, { title: 'beta' }, { title: 'gamma' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const filtered = await nestedHandler({
|
|
method: 'get',
|
|
path: '/user/u1/posts',
|
|
query: { 'filter[title]': 'alpha' },
|
|
client: nestedClient,
|
|
});
|
|
expect(filtered.status).toBe(200);
|
|
expect(filtered.body.data).toHaveLength(1);
|
|
expect(filtered.body.data[0].attributes.title).toBe('alpha');
|
|
|
|
const paged = await nestedHandler({
|
|
method: 'get',
|
|
path: '/user/u1/posts',
|
|
query: { 'page[limit]': '2', 'page[offset]': '0' },
|
|
client: nestedClient,
|
|
});
|
|
expect(paged.status).toBe(200);
|
|
expect(paged.body.data).toHaveLength(2);
|
|
});
|
|
|
|
it('updates a child scoped to parent (PATCH)', async () => {
|
|
const user1 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'original' }] },
|
|
},
|
|
include: { posts: true },
|
|
});
|
|
const postId = user1.posts[0]!.id;
|
|
|
|
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
|
|
|
|
// Cannot update a post that belongs to a different parent
|
|
const denied = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/user/u2/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: { type: 'Post', attributes: { title: 'denied-update' } },
|
|
},
|
|
});
|
|
expect(denied.status).toBe(404);
|
|
|
|
// Can update a post that belongs to the correct parent
|
|
const allowed = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: { type: 'Post', attributes: { title: 'updated' } },
|
|
},
|
|
});
|
|
expect(allowed.status).toBe(200);
|
|
expect(allowed.body.data.attributes.title).toBe('updated');
|
|
});
|
|
|
|
it('rejects nested PATCH when payload tries to change the parent relation', async () => {
|
|
const user1 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'post' }] },
|
|
},
|
|
include: { posts: true },
|
|
});
|
|
const postId = user1.posts[0]!.id;
|
|
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
|
|
|
|
const r = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'new' },
|
|
relationships: {
|
|
author: { data: { type: 'User', id: 'u2' } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
it('rejects nested PATCH when attributes contain camelCase FK field', async () => {
|
|
const user1 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'post' }] },
|
|
},
|
|
include: { posts: true },
|
|
});
|
|
const postId = user1.posts[0]!.id;
|
|
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
|
|
|
|
const r = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'Post',
|
|
attributes: { title: 'new', authorId: 'u2' },
|
|
},
|
|
},
|
|
});
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
it('deletes a child scoped to parent (DELETE)', async () => {
|
|
const user1 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'to-delete' }] },
|
|
},
|
|
include: { posts: true },
|
|
});
|
|
const postId = user1.posts[0]!.id;
|
|
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
|
|
|
|
// Cannot delete a post via the wrong parent
|
|
const denied = await nestedHandler({
|
|
method: 'delete',
|
|
path: `/user/u2/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(denied.status).toBe(404);
|
|
|
|
// Can delete via the correct parent
|
|
const allowed = await nestedHandler({
|
|
method: 'delete',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(allowed.status).toBe(200);
|
|
|
|
const gone = await nestedClient.post.findFirst({ where: { id: postId } });
|
|
expect(gone).toBeNull();
|
|
});
|
|
|
|
it('falls back to fetchRelated for non-configured 3-segment paths', async () => {
|
|
const user1 = await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'p1' }] },
|
|
},
|
|
});
|
|
|
|
// 'author' is a relation on Post, not a nestedRoute → fetchRelated
|
|
const post = await nestedClient.post.findFirst({ where: { authorId: 'u1' } });
|
|
const r = await nestedHandler({
|
|
method: 'get',
|
|
path: `/post/${post!.id}/author`,
|
|
client: nestedClient,
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.id).toBe(user1.id);
|
|
});
|
|
|
|
it('supports PATCH /:type/:id/:relationship for to-one nested update', async () => {
|
|
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
|
|
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
|
|
const post = await nestedClient.post.create({
|
|
data: { title: 'my-post', author: { connect: { id: 'u1' } } },
|
|
});
|
|
|
|
// PATCH /post/:id/author — update the to-one related author's attributes
|
|
const updated = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/post/${post.id}/author`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: { type: 'user', id: 'u1', attributes: { email: 'u1-new@test.com' } },
|
|
},
|
|
});
|
|
expect(updated.status).toBe(200);
|
|
expect(updated.body.data.attributes.email).toBe('u1-new@test.com');
|
|
expect(updated.body.links.self).toBe(`http://localhost/api/post/${post.id}/author`);
|
|
expect(updated.body.data.links.self).toBe(`http://localhost/api/post/${post.id}/author`);
|
|
|
|
// Verify the DB was actually updated
|
|
const dbUser = await nestedClient.user.findUnique({ where: { id: 'u1' } });
|
|
expect(dbUser?.email).toBe('u1-new@test.com');
|
|
|
|
// Attempting to change the back-relation (posts) via the nested route should be rejected
|
|
const rejected = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/post/${post.id}/author`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: {
|
|
type: 'user',
|
|
id: 'u1',
|
|
relationships: { posts: { data: [{ type: 'post', id: String(post.id) }] } },
|
|
},
|
|
},
|
|
});
|
|
expect(rejected.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 for PATCH /:type/:id/:relationship to-one when nestedRoutes is not enabled', async () => {
|
|
const api = new RestApiHandler({
|
|
schema: nestedClient.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
// nestedRoutes not enabled
|
|
});
|
|
const plainHandler = (args: any) =>
|
|
api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
|
|
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
|
|
const post = await nestedClient.post.create({
|
|
data: { title: 'my-post', author: { connect: { id: 'u1' } } },
|
|
});
|
|
|
|
const r = await plainHandler({
|
|
method: 'patch',
|
|
path: `/post/${post.id}/author`,
|
|
client: nestedClient,
|
|
requestBody: { data: { type: 'user', id: 'u1', attributes: { email: 'x@test.com' } } },
|
|
});
|
|
expect(r.status).toBe(400);
|
|
});
|
|
|
|
it('returns nested self-links in JSON:API responses for all nested operations', async () => {
|
|
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
|
|
|
|
// POST /user/u1/posts — nested create
|
|
const created = await nestedHandler({
|
|
method: 'post',
|
|
path: '/user/u1/posts',
|
|
client: nestedClient,
|
|
requestBody: { data: { type: 'post', attributes: { title: 'hello' } } },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
const postId = created.body.data.id;
|
|
expect(created.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
expect(created.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
|
|
// GET /user/u1/posts/:id — nested single read
|
|
const single = await nestedHandler({
|
|
method: 'get',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(single.status).toBe(200);
|
|
expect(single.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
expect(single.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
|
|
// PATCH /user/u1/posts/:id — nested update
|
|
const updated = await nestedHandler({
|
|
method: 'patch',
|
|
path: `/user/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: { data: { type: 'post', id: String(postId), attributes: { title: 'updated' } } },
|
|
});
|
|
expect(updated.status).toBe(200);
|
|
expect(updated.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
expect(updated.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`);
|
|
});
|
|
|
|
it('works with modelNameMapping on both parent and child segments', async () => {
|
|
const mappedApi = new RestApiHandler({
|
|
schema: nestedClient.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
modelNameMapping: { User: 'users', Post: 'posts' },
|
|
nestedRoutes: true,
|
|
});
|
|
const mappedHandler = (args: any) =>
|
|
mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'mapped-post' }] },
|
|
},
|
|
});
|
|
await nestedClient.user.create({
|
|
data: { id: 'u2', email: 'u2@test.com' },
|
|
});
|
|
|
|
const collection = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u1/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(collection.status).toBe(200);
|
|
expect(collection.body.data).toHaveLength(1);
|
|
expect(collection.body.data[0].attributes.title).toBe('mapped-post');
|
|
|
|
// Parent with no posts → 200 with empty collection
|
|
const denied = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u2/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(denied.status).toBe(200);
|
|
expect(denied.body.data).toHaveLength(0);
|
|
});
|
|
|
|
it('falls back to fetchRelated for mapped child names without nestedRoutes', async () => {
|
|
const mappedApi = new RestApiHandler({
|
|
schema: nestedClient.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
modelNameMapping: { User: 'users', Post: 'posts' },
|
|
});
|
|
const mappedHandler = (args: any) =>
|
|
mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
|
|
await nestedClient.user.create({
|
|
data: {
|
|
id: 'u1',
|
|
email: 'u1@test.com',
|
|
posts: { create: [{ title: 'mapped-fallback-post' }] },
|
|
},
|
|
});
|
|
await nestedClient.user.create({
|
|
data: { id: 'u2', email: 'u2@test.com' },
|
|
});
|
|
|
|
const collection = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u1/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(collection.status).toBe(200);
|
|
expect(collection.body.data).toHaveLength(1);
|
|
expect(collection.body.data[0].attributes.title).toBe('mapped-fallback-post');
|
|
|
|
const empty = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u2/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(empty.status).toBe(200);
|
|
expect(empty.body.data).toHaveLength(0);
|
|
});
|
|
|
|
it('exercises mapped nested-route mutations and verifies link metadata', async () => {
|
|
const mappedApi = new RestApiHandler({
|
|
schema: nestedClient.$schema,
|
|
endpoint: 'http://localhost/api',
|
|
modelNameMapping: { User: 'users', Post: 'posts' },
|
|
nestedRoutes: true,
|
|
});
|
|
const mappedHandler = (args: any) =>
|
|
mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
|
|
|
|
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
|
|
|
|
// POST /users/u1/posts — nested create via mapped route
|
|
const created = await mappedHandler({
|
|
method: 'post',
|
|
path: '/users/u1/posts',
|
|
client: nestedClient,
|
|
requestBody: { data: { type: 'posts', attributes: { title: 'mapped-create' } } },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
const postId = created.body.data.id;
|
|
expect(created.body.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`);
|
|
expect(created.body.data.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`);
|
|
|
|
// GET /users/u1/posts — list should contain the new post
|
|
const afterCreate = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u1/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(afterCreate.status).toBe(200);
|
|
expect(afterCreate.body.data).toHaveLength(1);
|
|
expect(afterCreate.body.links.self).toBe('http://localhost/api/users/u1/posts');
|
|
|
|
// PATCH /users/u1/posts/:id — nested update via mapped route
|
|
const updated = await mappedHandler({
|
|
method: 'patch',
|
|
path: `/users/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
requestBody: {
|
|
data: { type: 'posts', id: String(postId), attributes: { title: 'mapped-updated' } },
|
|
},
|
|
});
|
|
expect(updated.status).toBe(200);
|
|
expect(updated.body.data.attributes.title).toBe('mapped-updated');
|
|
expect(updated.body.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`);
|
|
expect(updated.body.data.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`);
|
|
|
|
// DELETE /users/u1/posts/:id — nested delete via mapped route
|
|
const deleted = await mappedHandler({
|
|
method: 'delete',
|
|
path: `/users/u1/posts/${postId}`,
|
|
client: nestedClient,
|
|
});
|
|
expect(deleted.status).toBe(200);
|
|
|
|
// GET /users/u1/posts — list should now be empty
|
|
const afterDelete = await mappedHandler({
|
|
method: 'get',
|
|
path: '/users/u1/posts',
|
|
client: nestedClient,
|
|
});
|
|
expect(afterDelete.status).toBe(200);
|
|
expect(afterDelete.body.data).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|