zenstack/packages/server/test/api/rest.test.ts
Yiming Cao ff0808d359
refactor: revised error system (#377)
* refactor: revised error system

* addressing PR comments
2025-11-06 21:59:27 -08:00

3166 lines
120 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 () => {
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 },
},
},
});
// 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);
// 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 });
// 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,
title: `Post${i}`,
})),
},
},
});
// 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,
title: `Post${i}`,
})),
},
},
});
// 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
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',
},
});
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', authorId: 1 },
});
// post is exposed using the `id` field
r = await handler({
method: 'get',
path: '/post/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',
});
});
});
});