mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
3166 lines
120 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|
|
});
|