From 3cb7f77cceae1901a1f3848ba2f96d50d58d6618 Mon Sep 17 00:00:00 2001 From: Jay Cammarano <67079013+jaycammarano@users.noreply.github.com> Date: Mon, 25 Oct 2021 20:41:31 -0400 Subject: [PATCH] End-to-end tests for a many to many relationship (#9021) * get many m2o, create many m2o, expand seedTable() * get many error handling * added artists_events relation * test for artists_events relations * test for getOne m2m * get many artists_events * create one many-to-many * updated createMany() for join tables * create many artists_events * tests for update one many to many * removed stray "only" * update many many to many * improved update many * tests for delete one artists_events * delete many many to many tests * update one + delete one with no relations * removed only * delete many artists with no relations --- tests/api/items.test.ts | 410 ++++++++++++++++++++- tests/setup/seeds/02_directus_relations.js | 15 + tests/setup/utils/factories.ts | 54 ++- 3 files changed, 456 insertions(+), 23 deletions(-) diff --git a/tests/api/items.test.ts b/tests/api/items.test.ts index 5c1708e7f5..23c9d9854f 100644 --- a/tests/api/items.test.ts +++ b/tests/api/items.test.ts @@ -3,7 +3,7 @@ import request from 'supertest'; import config from '../config'; import { getDBsToTest } from '../get-dbs-to-test'; import knex, { Knex } from 'knex'; -import { createArtist, createGuest, createMany, seedTable } from '../setup/utils/factories'; +import { createArtist, createEvent, createGuest, createMany, seedTable, Item } from '../setup/utils/factories'; describe('/items', () => { const databases = new Map(); @@ -18,11 +18,12 @@ describe('/items', () => { afterAll(async () => { for (const [_vendor, connection] of databases) { - connection.destroy(); + await connection('guests').truncate(); + await connection.destroy(); } }); - describe('/:collection/:id', () => { + describe('/:collection/:id GET', () => { it.each(getDBsToTest())('%p retrieves one artist', async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; seedTable(databases.get(vendor)!, 1, 'artists', createArtist()); @@ -37,11 +38,9 @@ describe('/items', () => { }); it.each(getDBsToTest())(`%p retrieves a guest's favorite artist`, async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; - const artist = createArtist(); const guest = createGuest(); - const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', artist, { + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { select: ['id'], - where: ['name', artist.name], }); guest.favorite_artist = insertedArtist[0].id; const insertedGuest = await seedTable(databases.get(vendor)!, 1, 'guests', guest, { @@ -55,13 +54,40 @@ describe('/items', () => { .expect('Content-Type', /application\/json/) .expect(200); - expect(await response.body.data).toMatchObject({ favorite_artist: { name: expect.any(String) } }); + expect(response.body.data).toMatchObject({ favorite_artist: { name: expect.any(String) } }); }); + it.each(getDBsToTest())(`%p retrieves an artist and an event off the artists_events table`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 1, 'events', createEvent(), { + select: ['id'], + }); + const relation = await seedTable( + databases.get(vendor)!, + 1, + 'artists_events', + { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }, + { select: ['id'], where: ['events_id', insertedEvent[insertedEvent.length - 1].id] } + ); + const response = await request(url) + .get(`/items/artists_events/${relation[0].id}?fields[]=artists_id.name&fields[]=events_id.cost`) + .set('Authorization', 'Bearer AdminToken') + .expect('Content-Type', /application\/json/) + .expect(200); + expect(response.body.data).toMatchObject({ + artists_id: { name: expect.any(String) }, + events_id: { cost: expect.any(Number) }, + }); + }); describe('Error handling', () => { it.each(getDBsToTest())('%p returns an error when an invalid id is used', async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; - seedTable(databases.get(vendor)!, 1, 'artists', createArtist()); const response = await axios .get(`${url}/items/artists/invalid_id`, { @@ -88,7 +114,6 @@ describe('/items', () => { }); it.each(getDBsToTest())('%p returns an error when an invalid table is used', async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; - seedTable(databases.get(vendor)!, 1, 'artists', createArtist()); const response = await axios .get(`${url}/items/invalid_table/1`, { @@ -108,19 +133,198 @@ describe('/items', () => { }); }); }); + describe('/:collection/:id PATCH', () => { + it.each(getDBsToTest())(`%p updates one artist's name with no relations`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const body = { name: 'Tommy Cash' }; + const response: any = await axios.patch( + `${url}/items/artists/${insertedArtist[insertedArtist.length - 1].id}`, + body, + { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + } + ); + + expect(response.data.data).toMatchObject({ + id: insertedArtist[insertedArtist.length - 1].id, + name: 'Tommy Cash', + }); + }); + it.each(getDBsToTest())(`%p updates one artists_events to a different artist`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 1, 'events', createEvent(), { + select: ['id'], + }); + const relation = await seedTable( + databases.get(vendor)!, + 1, + 'artists_events', + { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }, + { select: ['id'], where: ['events_id', insertedEvent[insertedEvent.length - 1].id] } + ); + const body = { artists_id: insertedArtist[0].id }; + const response: any = await axios.patch(`${url}/items/artists_events/${relation[0].id}`, body, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + + expect(response.data.data).toMatchObject({ + artists_id: insertedArtist[0].id, + }); + }); + }); + describe('/:collection/:id DELETE', () => { + it.each(getDBsToTest())(`%p deletes an artist with no relations`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + + const response: any = await axios.delete(`${url}/items/artists/${insertedArtist[insertedArtist.length - 1].id}`, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + + expect(response.data.data).toBe(undefined); + expect( + await databases.get(vendor)!('artists') + .select('*') + .where('id', insertedArtist[insertedArtist.length - 1].id) + ).toStrictEqual([]); + }); + it.each(getDBsToTest())(`%p deletes an artists_events without deleting the artist or event`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 1, 'events', createEvent(), { + select: ['id'], + }); + const item = await seedTable( + databases.get(vendor)!, + 1, + 'artists_events', + { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }, + { select: ['id'], where: ['events_id', insertedEvent[insertedEvent.length - 1].id] } + ); + const response: any = await axios.delete(`${url}/items/artists_events/${item[0].id}`, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + + expect(response.data.data).toBe(undefined); + expect(await databases.get(vendor)!('artists_events').select('*').where('id', item[0].id)).toStrictEqual([]); + expect( + await databases.get(vendor)!('artists') + .select('id') + .where('id', insertedArtist[insertedArtist.length - 1].id) + ).toStrictEqual([insertedArtist[insertedArtist.length - 1]]); + expect( + await databases.get(vendor)!('artists') + .select('id') + .where('id', insertedEvent[insertedEvent.length - 1].id) + ).toStrictEqual([insertedEvent[insertedEvent.length - 1]]); + }); + }); describe('/:collection GET', () => { it.each(getDBsToTest())('%p retrieves all items from artist table with no relations', async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; - seedTable(databases.get(vendor)!, 100, 'artists', createArtist); + seedTable(databases.get(vendor)!, 50, 'artists', createArtist); const response = await request(url) .get('/items/artists') .set('Authorization', 'Bearer AdminToken') .expect('Content-Type', /application\/json/) .expect(200); - expect(response.body.data.length).toBe(100); - expect(Object.keys(response.body.data[0]).sort()).toStrictEqual(['id', 'members', 'name']); + expect(response.body.data.length).toBeGreaterThanOrEqual(50); + expect(response.body.data[0]).toMatchObject({ + id: expect.any(Number), + name: expect.any(String), + }); + }); + it.each(getDBsToTest())('%p retrieves all items from guest table with favorite_artist', async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + await seedTable(databases.get(vendor)!, 10, 'artists', createArtist); + seedTable(databases.get(vendor)!, 1, 'guests', createMany(createGuest, 10, { favorite_artist: 10 })!); + + const response = await request(url) + .get('/items/guests') + .set('Authorization', 'Bearer AdminToken') + .expect('Content-Type', /application\/json/) + .expect(200); + + expect(response.body.data.length).toBeGreaterThanOrEqual(10); + expect(response.body.data[0]).toMatchObject({ + birthday: expect.any(String), + favorite_artist: expect.any(Number), + }); + }); + it.each(getDBsToTest())(`%p retrieves artists and events for each entry in artists_events`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 1, 'events', createEvent(), { + select: ['id'], + }); + await seedTable(databases.get(vendor)!, 10, 'artists_events', { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }); + const response = await request(url) + .get(`/items/artists_events?fields[]=artists_id.name&fields[]=events_id.cost`) + .set('Authorization', 'Bearer AdminToken') + .expect('Content-Type', /application\/json/) + .expect(200); + + expect(response.body.data[0]).toMatchObject({ + artists_id: { name: expect.any(String) }, + events_id: { cost: expect.any(Number) }, + }); + expect(response.body.data.length).toBeGreaterThanOrEqual(10); + }); + describe('Error handling', () => { + it.each(getDBsToTest())('%p returns an error when an invalid table is used', async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + + const response = await axios + .get(`${url}/items/invalid_table/`, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }) + .catch((error: any) => { + return error; + }); + + expect(response.response.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(response.response.status).toBe(403); + expect(response.response.statusText).toBe('Forbidden'); + expect(response.message).toBe('Request failed with status code 403'); + }); }); }); @@ -151,11 +355,37 @@ describe('/items', () => { }); expect(response.data.data).toMatchObject({ name: body.name, favorite_artist: expect.any(Number) }); }); + it.each(getDBsToTest())(`%p creates an artist_events entry`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 1, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 1, 'events', createEvent(), { + select: ['id'], + }); + const body = { + artists_id: insertedArtist[0].id, + events_id: insertedEvent[0].id, + }; + + const response: any = await axios.post(`${url}/items/artists_events`, body, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + + expect(response.data.data).toMatchObject({ + id: expect.any(Number), + artists_id: expect.any(Number), + events_id: expect.any(Number), + }); + }); }); describe('createMany', () => { it.each(getDBsToTest())('%p creates 5 artists', async (vendor) => { const url = `http://localhost:${config.ports[vendor]!}`; - const body = createMany(createArtist, 5); + const body = createMany(createArtist, 5)!; const response: any = await axios.post(`${url}/items/artists`, body, { headers: { Authorization: 'Bearer AdminToken', @@ -164,6 +394,42 @@ describe('/items', () => { }); expect(response.data.data.length).toBe(body.length); }); + it.each(getDBsToTest())('%p creates 5 users with a favorite_artist', async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + await seedTable(databases.get(vendor)!, 5, 'artists', createArtist); + const body = createMany(createGuest, 5, { favorite_artist: 5 })!; + + const response: any = await axios.post(`${url}/items/guests`, body, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + expect(response.data.data.length).toBe(body.length); + }); + it.each(getDBsToTest())(`%p creates 5 artist_events entries`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + await seedTable(databases.get(vendor)!, 5, 'artists', createArtist(), { + select: ['id'], + }); + await seedTable(databases.get(vendor)!, 5, 'events', createEvent(), { + select: ['id'], + }); + const body = createMany({}, 10, { events_id: 5, artists_id: 5 }); + + const response: any = await axios.post(`${url}/items/artists_events`, body, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + + expect(response.data.data.length).toBeGreaterThanOrEqual(10); + expect(response.data.data[0]).toMatchObject({ + artists_id: expect.any(Number), + events_id: expect.any(Number), + }); + }); }); describe('Error handling', () => { it.each(getDBsToTest())('%p returns an error when an invalid table is used', async (vendor) => { @@ -187,4 +453,122 @@ describe('/items', () => { }); }); }); + + describe('/:collection PATCH', () => { + it.each(getDBsToTest())(`%p updates many artists_events to a different artist`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 10, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 10, 'events', createEvent(), { + select: ['id'], + }); + const items = await seedTable( + databases.get(vendor)!, + 10, + 'artists_events', + { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }, + { select: ['id'], where: ['events_id', insertedEvent[insertedEvent.length - 1].id] } + ); + const keys: any[] = []; + Object.values(items).forEach((item: any) => { + keys.push(item.id); + }); + const body = { + keys: keys, + data: { events_id: insertedEvent[0].id }, + }; + const response: any = await axios.patch(`${url}/items/artists_events/?fields=events_id`, body, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + }); + for (let row = 0; row < response.data.data.length; row++) { + expect(response.data.data[row]).toMatchObject({ + events_id: insertedEvent[0].id, + }); + } + expect(response.data.data.length).toBe(keys.length); + }); + }); + describe('/:collection DELETE', () => { + it.each(getDBsToTest())(`%p deletes many artists with no relations`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const items = await seedTable(databases.get(vendor)!, 10, 'artists', createArtist(), { + select: ['id'], + }); + const body: any[] = []; + items.sort(function (a: any, b: any) { + return b.id - a.id; + }); + for (let row = 0; row < 10; row++) { + body.push(items[row].id); + } + const response: any = await axios.delete(`${url}/items/artists/`, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + }); + + expect(response.data.data).toBe(undefined); + for (let row = 0; row < body.length; row++) { + expect(await databases.get(vendor)!('artists').select('*').where('id', body[row])).toStrictEqual([]); + } + }); + it.each(getDBsToTest())(`%p deletes many artists_events without deleting the artists or events`, async (vendor) => { + const url = `http://localhost:${config.ports[vendor]!}`; + const insertedArtist = await seedTable(databases.get(vendor)!, 10, 'artists', createArtist(), { + select: ['id'], + }); + const insertedEvent = await seedTable(databases.get(vendor)!, 10, 'events', createEvent(), { + select: ['id'], + }); + const items = await seedTable( + databases.get(vendor)!, + 10, + 'artists_events', + { + artists_id: insertedArtist[insertedArtist.length - 1].id, + events_id: insertedEvent[insertedEvent.length - 1].id, + }, + { select: ['id'], where: ['events_id', insertedEvent[insertedEvent.length - 1].id] } + ); + const body: any[] = []; + items + .sort(function (a: any, b: any) { + return b.id - a.id; + }) + .forEach((item: Item) => { + body.push(item.id); + }); + const response: any = await axios.delete(`${url}/items/artists_events/`, { + headers: { + Authorization: 'Bearer AdminToken', + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + }); + + expect(response.data.data).toBe(undefined); + for (let row = 0; row < items.length; row++) { + expect(await databases.get(vendor)!('artists_events').select('*').where('id', items[row].id)).toStrictEqual([]); + } + expect( + await databases.get(vendor)!('artists') + .select('id') + .where('id', insertedArtist[insertedArtist.length - 1].id) + ).toStrictEqual([insertedArtist[insertedArtist.length - 1]]); + expect( + await databases.get(vendor)!('artists') + .select('id') + .where('id', insertedEvent[insertedEvent.length - 1].id) + ).toStrictEqual([insertedEvent[insertedEvent.length - 1]]); + }); + }); }); diff --git a/tests/setup/seeds/02_directus_relations.js b/tests/setup/seeds/02_directus_relations.js index 10fafd0f61..5a6c73dddd 100644 --- a/tests/setup/seeds/02_directus_relations.js +++ b/tests/setup/seeds/02_directus_relations.js @@ -5,5 +5,20 @@ exports.seed = function (knex) { many_field: 'favorite_artist', one_collection: 'artists', }, + { many_collection: 'artists_events', many_field: 'events_id', one_collection: 'artists' }, + { + many_collection: 'artists_events', + many_field: 'events_id', + one_collection: 'events', + one_field: 'artists', + junction_field: 'artists_id', + }, + { + many_collection: 'artists_events', + many_field: 'artists_id', + one_collection: 'artists', + one_field: 'events', + junction_field: 'events_id', + }, ]); }; diff --git a/tests/setup/utils/factories.ts b/tests/setup/utils/factories.ts index 1f0b4b9622..336ea370f9 100644 --- a/tests/setup/utils/factories.ts +++ b/tests/setup/utils/factories.ts @@ -39,7 +39,11 @@ type Event = { tags: string; }; -type Item = Guest | Artist | Tour | Organizer | Event; +type JoinTable = { + [column: string]: number; +}; + +export type Item = Guest | Artist | Tour | Organizer | Event | JoinTable; /* * Options Example: Artist @@ -67,13 +71,28 @@ export const seedTable = async function ( database: Knex, count: number, table: string, - factory: Item | (() => Item), + factory: Item | (() => Item) | Item[], options?: SeedOptions ): Promise { const row: Record = {}; - if (typeof factory === 'object') { - await database(table).insert(factory); - row[table] = row[table]! + 1; + if (Array.isArray(factory)) { + await database.batchInsert(table, factory, 200); + } else if (typeof factory === 'object') { + if (count > 1) { + try { + const fakeRows = []; + for (let i = 0; i < count; i++) { + fakeRows.push(factory); + row[table] = row[table]! + 1; + } + await database(table).insert(fakeRows); + } catch (error: any) { + throw new Error(error); + } + } else { + await database(table).insert(factory); + row[table] = row[table]! + 1; + } } else if (count >= 200) { try { let fakeRows: any[] = []; @@ -147,9 +166,9 @@ export const createOrganizer = (): Organizer => ({ company_name: `${name.firstName()} ${name.lastName()}`, }); -export const createMany = (factory: () => Item, count: number, options?: CreateManyOptions) => { +export const createMany = (factory: (() => Item) | Record, count: number, options?: CreateManyOptions) => { const items: Item[] = []; - if (options) { + if (options && typeof factory !== 'object') { for (let rows = 0; rows < count; rows++) { const item: any = factory(); for (const [column, max] of Object.entries(options)) { @@ -159,14 +178,29 @@ export const createMany = (factory: () => Item, count: number, options?: CreateM } return items; } - for (let rows = 0; rows < count; rows++) { - items.push(factory()); + if (options && typeof factory === 'object') { + for (let rows = 0; rows < count; rows++) { + const item: any = factory; + for (const [column, max] of Object.entries(options)) { + item[column] = getRandomInt(max); + } + items.push(item); + } + return items; + } else if (typeof factory !== 'object') { + for (let rows = 0; rows < count; rows++) { + items.push(factory()); + } } return items; }; function getRandomInt(max: number) { - return Math.floor(Math.random() * max); + let int = 0; + while (int === 0) { + int = Math.floor(Math.random() * max); + } + return int; } function randomDateTime(start: Date, end: Date) {