diff --git a/client/src/api.ts b/client/src/api.ts index 9322e9c..c5b7c6b 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -142,4 +142,50 @@ export interface ModifyArtistRequest { export interface ModifyArtistResponse {} export function checkModifyArtistRequest(req:any): boolean { return true; +} + +// Create a new tag (POST). +export const CreateTagEndpoint = '/tag'; +export interface CreateTagRequest { + name: String; + parentId?: Number; +} +export interface CreateTagResponse { + id: Number; +} +export function checkCreateTagRequest(req:any): boolean { + return "body" in req && + "name" in req.body; +} + +// Modify an existing tag (PUT). +export const ModifyTagEndpoint = '/tag/:id'; +export interface ModifyTagRequest { + name?: String, + parentId?: Number; +} +export interface ModifyTagResponse {} +export function checkModifyTagRequest(req:any): boolean { + return true; +} + +// Query for tags. +export const QueryTagEndpoint = '/tag/query'; +export interface QueryTagsRequest {} +export interface QueryTagsResponse { + ids: Number[] +} +export function checkQueryTagsRequest(req:any): boolean { + return true; +} + +// Get tag details (GET). +export const TagDetailsEndpoint = '/tag/:id'; +export interface TagDetailsRequest {} +export interface TagDetailsResponse { + name: String, + parentId?: Number, +} +export function checkTagDetailsRequest(req:any): boolean { + return true; } \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 61a78f9..eaa24e9 100644 --- a/server/app.ts +++ b/server/app.ts @@ -9,6 +9,9 @@ import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetailsEndpointH import { SongDetailsEndpointHandler } from './endpoints/SongDetailsEndpointHandler'; import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtistEndpointHandler'; import { ModifySongEndpointHandler } from './endpoints/ModifySongEndpointHandler'; +import { CreateTagEndpointHandler } from './endpoints/CreateTagEndpointHandler'; +import { ModifyTagEndpointHandler } from './endpoints/ModifyTagEndpointHandler'; +import { TagDetailsEndpointHandler } from './endpoints/TagDetailsEndpointHandler'; import * as endpointTypes from './endpoints/types'; const invokeHandler = (handler:endpointTypes.EndpointHandler) => { @@ -38,6 +41,9 @@ const SetupApp = (app: any) => { app.put(api.ModifySongEndpoint, invokeHandler(ModifySongEndpointHandler)); app.get(api.SongDetailsEndpoint, invokeHandler(SongDetailsEndpointHandler)); app.get(api.ArtistDetailsEndpoint, invokeHandler(ArtistDetailsEndpointHandler)); + app.post(api.CreateTagEndpoint, invokeHandler(CreateTagEndpointHandler)); + app.put(api.ModifyTagEndpoint, invokeHandler(ModifyTagEndpointHandler)); + app.get(api.TagDetailsEndpoint, invokeHandler(TagDetailsEndpointHandler)); } export { SetupApp } \ No newline at end of file diff --git a/server/endpoints/CreateTagEndpointHandler.ts b/server/endpoints/CreateTagEndpointHandler.ts new file mode 100644 index 0000000..ab9b971 --- /dev/null +++ b/server/endpoints/CreateTagEndpointHandler.ts @@ -0,0 +1,48 @@ +const models = require('../models'); +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; + +export const CreateTagEndpointHandler: EndpointHandler = async (req: any, res: any) => { + if (!api.checkCreateTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid CreateTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.CreateTagRequest = req.body; + + const getTag = async (id: Number) => { + const tag = await models.Tag.findAll({ + where: { + id: id + } + }); + if (tag.length != 1) { + const e: EndpointError = { + internalMessage: 'There is no tag with id ' + id + '.', + httpStatus: 400 + }; + throw e; + } + return tag[0]; + } + + // If applicable, start retrieving the new parent tag. + const maybeNewParentPromise: Promise | Promise = + (reqObject.parentId) ? getTag(reqObject.parentId) : (async () => { return undefined })(); + + (async () => { + const maybeParent = await maybeNewParentPromise; + const tag = await models.Tag.create({ + name: reqObject.name + }); + reqObject.parentId && await tag.setParent(maybeParent); + await tag.save(); + const responseObject: api.CreateTagResponse = { + id: tag.id + }; + res.status(200).send(responseObject); + })() + .catch(catchUnhandledErrors); +} \ No newline at end of file diff --git a/server/endpoints/ModifyTagEndpointHandler.ts b/server/endpoints/ModifyTagEndpointHandler.ts new file mode 100644 index 0000000..076c354 --- /dev/null +++ b/server/endpoints/ModifyTagEndpointHandler.ts @@ -0,0 +1,51 @@ +const models = require('../models'); +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import tag from '../models/tag'; + +export const ModifyTagEndpointHandler: EndpointHandler = async (req: any, res: any) => { + if (!api.checkModifySongRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid ModifyTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.ModifyTagRequest = req.body; + + const getTag = async (id:Number) => { + const tag = await models.Tag.findAll({ + where: { + id: id + } + }); + if(tag.length != 1) { + const e: EndpointError = { + internalMessage: 'There is no tag with id ' + id + '.', + httpStatus: 400 + }; + throw e; + } + return tag[0]; + } + + // If applicable, start retrieving the new parent tag. + const maybeNewParentPromise:Promise|Promise = + (reqObject.parentId) ? getTag(reqObject.parentId) : (async () => { return undefined })(); + + // Start retrieving the tag to modify. + var tagInstancePromise: Promise = getTag(req.params.id); + + // Upon finish retrieving artists and albums, modify the song. + await Promise.all([maybeNewParentPromise, tagInstancePromise]) + .then(async (values: any) => { + var [maybeParent, tag] = values; + if(reqObject.name) { tag.setName(reqObject.name) }; + if(reqObject.parentId) { tag.setParent(maybeParent) }; + await tag.save(); + }) + .then(() => { + res.status(200).send({}); + }) + .catch(catchUnhandledErrors); +} \ No newline at end of file diff --git a/server/endpoints/TagDetailsEndpointHandler.ts b/server/endpoints/TagDetailsEndpointHandler.ts new file mode 100644 index 0000000..581ab49 --- /dev/null +++ b/server/endpoints/TagDetailsEndpointHandler.ts @@ -0,0 +1,39 @@ +const models = require('../models'); +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; + +export const TagDetailsEndpointHandler: EndpointHandler = async (req: any, res: any) => { + if (!api.checkTagDetailsRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid TagDetails request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + await models.Tag.findAll({ + where: { + id: req.params.id + }, + include: [{ + model: models.Tag, + as: 'parent' + }] + }) + .then((tags: any[]) => { + if (tags.length != 1) { + const e: EndpointError = { + internalMessage: 'There is no tag with id ' + req.params.id + '.', + httpStatus: 400 + }; + throw e; + } + let tag = tags[0]; + var response: api.TagDetailsResponse = { + name: tag.name, + }; + if(tag.parent) { response['parentId'] = tag.parent.id; } + res.send(response); + }) + .catch(catchUnhandledErrors); +} \ No newline at end of file diff --git a/server/models/album.js b/server/models/album.js index 7380aed..83ce74b 100644 --- a/server/models/album.js +++ b/server/models/album.js @@ -5,8 +5,9 @@ module.exports = (sequelize, DataTypes) => { }); Album.associate = function (models) { - models.Album.belongsToMany(models.Song, { through: "SongAlbums" }) - models.Album.belongsToMany(models.Artist, { through: "AlbumArtists" }) + models.Album.belongsToMany(models.Song, { through: "SongAlbums" }); + models.Album.belongsToMany(models.Artist, { through: "AlbumArtists" }); + models.Album.belongsToMany(models.Tag, { through: 'AlbumTags' }); }; return Album; diff --git a/server/models/artist.js b/server/models/artist.js index 4abefea..d56bdb1 100644 --- a/server/models/artist.js +++ b/server/models/artist.js @@ -6,7 +6,8 @@ module.exports = (sequelize, DataTypes) => { Artist.associate = function (models) { models.Artist.belongsToMany(models.Song, { through: "SongArtists" }); - models.Artist.belongsToMany(models.Album, { through: "AlbumArtists" }) + models.Artist.belongsToMany(models.Album, { through: "AlbumArtists" }); + models.Artist.belongsToMany(models.Tag, { through: 'ArtistTags' }); }; return Artist; diff --git a/server/models/song.js b/server/models/song.js index 8382e2f..f21785d 100644 --- a/server/models/song.js +++ b/server/models/song.js @@ -7,6 +7,7 @@ module.exports = (sequelize, DataTypes) => { Song.associate = function (models) { models.Song.belongsToMany(models.Artist, { through: "SongArtists" }); models.Song.belongsToMany(models.Album, { through: "SongAlbums" }); + models.Song.belongsToMany(models.Tag, { through: 'SongTags' }); }; return Song; diff --git a/server/models/tag.js b/server/models/tag.js new file mode 100644 index 0000000..0478872 --- /dev/null +++ b/server/models/tag.js @@ -0,0 +1,14 @@ +module.exports = (sequelize, DataTypes) => { + var Tag = sequelize.define('Tag', { + name: DataTypes.STRING, + }); + + Tag.associate = function (models) { + models.Tag.hasOne(models.Tag, { as: 'parent' }); + models.Tag.belongsToMany(models.Artist, { through: 'ArtistTags' }); + models.Tag.belongsToMany(models.Album, { through: 'AlbumTags' }); + models.Tag.belongsToMany(models.Song, { through: 'SongTags' }); + }; + + return Tag; +}; \ No newline at end of file diff --git a/server/test/integration/flows/ModifyArtistFlow.js b/server/test/integration/flows/ArtistFlow.js similarity index 58% rename from server/test/integration/flows/ModifyArtistFlow.js rename to server/test/integration/flows/ArtistFlow.js index d866e3e..ac5414e 100644 --- a/server/test/integration/flows/ModifyArtistFlow.js +++ b/server/test/integration/flows/ArtistFlow.js @@ -14,6 +14,42 @@ async function init() { return app; } +describe('POST /artist with no name', () => { + it('should fail', done => { + init().then((app) => { + chai + .request(app) + .post('/artist') + .send({}) + .then((res) => { + expect(res).to.have.status(400); + done(); + }); + }); + }); +}); + +describe('POST /artist with a correct request', () => { + it('should succeed', done => { + init().then((app) => { + chai + .request(app) + .post('/artist') + .send({ + name: "MyArtist" + }) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body).to.deep.equal({ + id: 1 + }); + done(); + }); + }); + }); +}); + + describe('PUT /artist on nonexistent artist', () => { it('should fail', done => { init().then((app) => { @@ -38,9 +74,10 @@ describe('PUT /artist with an existing artist', () => { var req = chai.request(app).keepOpen(); helpers.createArtist(req, { name: "MyArtist" }, 200, { id: 1 }) .then(() => helpers.modifyArtist(req, 1, { name: "MyNewArtist" }, 200) ) - .then(() => helpers.checkArtist(req, 1, 200, { name: "MyNewArtist" } ) ) + .then(() => helpers.checkArtist(req, 1, 200, { name: "MyNewArtist", storeLinks: [] } ) ) .then(req.close) .then(done); }); }); }); + diff --git a/server/test/integration/flows/CreateSongFlow.js b/server/test/integration/flows/CreateSongFlow.js deleted file mode 100644 index 0e332b0..0000000 --- a/server/test/integration/flows/CreateSongFlow.js +++ /dev/null @@ -1,107 +0,0 @@ -const chai = require('chai'); -const chaiHttp = require('chai-http'); -const express = require('express'); -const models = require('../../../models'); -import { SetupApp } from '../../../app'; -import { expect } from 'chai'; -import * as helpers from './helpers'; - -async function init() { - chai.use(chaiHttp); - const app = express(); - SetupApp(app); - await models.sequelize.sync({ force: true }); - return app; -} - -describe('POST /song with no title', () => { - it('should fail', done => { - init().then((app) => { - chai - .request(app) - .post('/song') - .send({}) - .then((res) => { - expect(res).to.have.status(400); - done(); - }); - }) - }); -}); - -describe('POST /song with only a title', () => { - it('should return the first available id', done => { - init().then(async(app) => { - chai - .request(app) - .post('/song') - .send({ - title: "MySong" - }) - .then((res) => { - expect(res).to.have.status(200); - expect(res.body).to.deep.equal({ - id: 1 - }); - done(); - }); - }) - }); -}); - -describe('POST /song with a nonexistent artist Id', () => { - it('should fail', done => { - init().then(async (app) => { - chai - .request(app) - .post('/song') - .send({ - title: "MySong", - artistIds: [1] - }) - .then((res) => { - expect(res).to.have.status(400); - done(); - }); - }) - }); -}); - -describe('POST /song with an existing artist Id', () => { - it('should succeed', done => { - init().then((app) => { - var req = chai.request(app).keepOpen(); - helpers.createArtist(req, { name: "MyArtist" }, 200, { id: 1 }) - .then(() => helpers.createSong(req, { title: "MySong", artistIds: [ 1 ] }, 200, { id: 1 }) ) - .then(req.close) - .then(done); - }); - }); -}); - -describe('POST /song with two existing artist Ids', () => { - it('should succeed', done => { - init().then((app) => { - var req = chai.request(app).keepOpen(); - helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1 }) - .then(() => helpers.createArtist(req, { name: "Artist2" }, 200, { id: 2 }) ) - .then(() => helpers.createSong(req, { title: "MySong", artistIds: [1, 2] }, 200, { id: 1 }) ) - .then(req.close) - .then(done); - }); - }); -}); - -describe('POST /song with an existent and a nonexistent artist Id', () => { - it('should fail', done => { - init().then((app) => { - var req = chai.request(app).keepOpen(); - helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1 }) - .then(() => helpers.createSong(req, { title: "MySong", artistIds: [1, 2] }, 400) ) - .then(req.close) - .then(done); - }); - }); -}); - -export { }; \ No newline at end of file diff --git a/server/test/integration/flows/QuerySongsFlow.js b/server/test/integration/flows/SongFlow.js similarity index 64% rename from server/test/integration/flows/QuerySongsFlow.js rename to server/test/integration/flows/SongFlow.js index b6cd3f8..8560d86 100644 --- a/server/test/integration/flows/QuerySongsFlow.js +++ b/server/test/integration/flows/SongFlow.js @@ -14,6 +14,97 @@ async function init() { return app; } +describe('POST /song with no title', () => { + it('should fail', done => { + init().then((app) => { + chai + .request(app) + .post('/song') + .send({}) + .then((res) => { + expect(res).to.have.status(400); + done(); + }); + }) + }); +}); + +describe('POST /song with only a title', () => { + it('should return the first available id', done => { + init().then(async(app) => { + chai + .request(app) + .post('/song') + .send({ + title: "MySong" + }) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body).to.deep.equal({ + id: 1 + }); + done(); + }); + }) + }); +}); + +describe('POST /song with a nonexistent artist Id', () => { + it('should fail', done => { + init().then(async (app) => { + chai + .request(app) + .post('/song') + .send({ + title: "MySong", + artistIds: [1] + }) + .then((res) => { + expect(res).to.have.status(400); + done(); + }); + }) + }); +}); + +describe('POST /song with an existing artist Id', () => { + it('should succeed', done => { + init().then((app) => { + var req = chai.request(app).keepOpen(); + helpers.createArtist(req, { name: "MyArtist" }, 200, { id: 1 }) + .then(() => helpers.createSong(req, { title: "MySong", artistIds: [ 1 ] }, 200, { id: 1 }) ) + .then(req.close) + .then(done); + }); + }); +}); + +describe('POST /song with two existing artist Ids', () => { + it('should succeed', done => { + init().then((app) => { + var req = chai.request(app).keepOpen(); + helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1 }) + .then(() => helpers.createArtist(req, { name: "Artist2" }, 200, { id: 2 }) ) + .then(() => helpers.createSong(req, { title: "MySong", artistIds: [1, 2] }, 200, { id: 1 }) ) + .then(req.close) + .then(done); + }); + }); +}); + +describe('POST /song with an existent and a nonexistent artist Id', () => { + it('should fail', done => { + init().then((app) => { + var req = chai.request(app).keepOpen(); + helpers.createArtist(req, { name: "Artist1" }, 200, { id: 1 }) + .then(() => helpers.createSong(req, { title: "MySong", artistIds: [1, 2] }, 400) ) + .then(req.close) + .then(done); + }); + }); +}); + + describe('POST /song/query with no songs', () => { it('should give empty list', done => { init().then((app) => { diff --git a/server/test/integration/flows/CreateArtistFlow.js b/server/test/integration/flows/TagFlow.js similarity index 62% rename from server/test/integration/flows/CreateArtistFlow.js rename to server/test/integration/flows/TagFlow.js index 1813cf7..e2ab069 100644 --- a/server/test/integration/flows/CreateArtistFlow.js +++ b/server/test/integration/flows/TagFlow.js @@ -14,12 +14,12 @@ async function init() { return app; } -describe('POST /artist with no name', () => { +describe('POST /tag with no name', () => { it('should fail', done => { init().then((app) => { chai .request(app) - .post('/artist') + .post('/tag') .send({}) .then((res) => { expect(res).to.have.status(400); @@ -29,14 +29,14 @@ describe('POST /artist with no name', () => { }); }); -describe('POST /artist with a correct request', () => { +describe('POST /tag with a correct request', () => { it('should succeed', done => { init().then((app) => { chai .request(app) - .post('/artist') + .post('/tag') .send({ - name: "MyArtist" + name: "MyTag" }) .then((res) => { expect(res).to.have.status(200); @@ -48,3 +48,16 @@ describe('POST /artist with a correct request', () => { }); }); }); + +describe('POST /tag with a parent', () => { + it('should succeed', done => { + init().then((app) => { + var req = chai.request(app).keepOpen(); + helpers.createTag(req, { name: "Tag1" }, 200, { id: 1 }) + .then(() => helpers.createTag(req, { name: "Tag2", parentId: 1 }, 200, { id: 2 }) ) + .then(() => helpers.checkTag(req, 2, 200, { name: "Tag2", parentId: 1 })) + .then(req.close) + .then(done); + }); + }); +}); \ No newline at end of file diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index 8a217e0..34aa52d 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -56,4 +56,47 @@ export async function checkArtist( expectStatus && expect(res).to.have.status(expectStatus); expectResponse && expect(res.body).to.deep.equal(expectResponse); }) +} + +export async function createTag( + req, + props = { name: "Tag" }, + expectStatus = undefined, + expectResponse = undefined +) { + await req + .post('/tag') + .send(props) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + }); +} + +export async function modifyTag( + req, + id = 1, + props = { name: "NewTag" }, + expectStatus = undefined, +) { + await req + .put('/tag/' + id) + .send(props) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + }); +} + +export async function checkTag( + req, + id, + expectStatus = undefined, + expectResponse = undefined, +) { + await req + .get('/tag/' + id) + .then((res) => { + expectStatus && expect(res).to.have.status(expectStatus); + expectResponse && expect(res.body).to.deep.equal(expectResponse); + }) } \ No newline at end of file