const chai = require('chai'); const chaiHttp = require('chai-http'); const express = require('express'); import { expect } from 'chai'; import { SetupApp } from '../../../app'; import { ReferenceDatabase } from '../../reference_model/DBReferenceModel'; import { randomDBAction, RandomDBActionDistribution, DBActionType, applyReferenceDBAction, applyRealDBAction, DBAction } from '../../reference_model/randomGen'; import * as helpers from '../helpers'; import seedrandom from 'seedrandom'; import { AlbumWithRefsWithId, Artist, ArtistWithRefsWithId, DBImportResponse, IDMappings, TagWithRefsWithId, TrackWithRefs, TrackWithRefsWithId } from '../../../../client/src/api/api'; import sampleDB from '../sampleDB'; let stringify = require('json-stringify-deterministic'); let _ = require('lodash'); let tmp = require('tmp'); let fs = require('fs'); async function init() { chai.use(chaiHttp); const app = express(); const knex = await helpers.initTestDB(); SetupApp(app, knex, ''); var agent = chai.request.agent(app); return agent; } // Alters a response from a real or mock DB so that they can be deep-compared // and only non-trivial differences trigger an error. function normalizeResponse(response: any) { let r: any = _.cloneDeep(response); if (r && 'id' in r) { r.id = ''; } return r; } // Alters a database export / reference database model so that it can be compared // to another so that only non-trivial differences trigger an error. function normalizeDB(oldDb: ReferenceDatabase) { let db: ReferenceDatabase = _.cloneDeep(oldDb); // Apply a deterministic sorting. // TODO: sorting by name is not deterministic. for (const userId in db) { db[userId].tracks.sort((a: any, b: any) => a.name.localeCompare(b.name)) db[userId].albums.sort((a: any, b: any) => a.name.localeCompare(b.name)) db[userId].artists.sort((a: any, b: any) => a.name.localeCompare(b.name)) db[userId].tags.sort((a: any, b: any) => a.name.localeCompare(b.name)) } // Re-map IDs. interface IDMap { map: Map, highestId: number, }; let trackMap: IDMap = { map: new Map(), highestId: 0 }; let albumMap: IDMap = { map: new Map(), highestId: 0 }; let artistMap: IDMap = { map: new Map(), highestId: 0 }; let tagMap: IDMap = { map: new Map(), highestId: 0 }; let remapId = (id: number, map: IDMap) => { if (map.map.has(id)) { return map.map.get(id) as number; } let newId: number = map.highestId + 1; map.map.set(id, newId); map.highestId = newId; return newId; } for (const userId in db) { // First remap the IDs only, ignoring references db[userId].tracks.forEach((x: TrackWithRefsWithId) => { x.id = remapId(x.id, trackMap); }); db[userId].albums.forEach((x: AlbumWithRefsWithId) => { x.id = remapId(x.id, albumMap); }) db[userId].artists.forEach((x: ArtistWithRefsWithId) => { x.id = remapId(x.id, artistMap); }) db[userId].tags.forEach((x: TagWithRefsWithId) => { x.id = remapId(x.id, tagMap); }) } for (const userId in db) { // Now remap the references. db[userId].tracks.forEach((x: TrackWithRefsWithId) => { x.tagIds = x.tagIds.map((id: number) => remapId(id, tagMap)); x.artistIds = x.artistIds.map((id: number) => remapId(id, artistMap)); x.albumId = x.albumId ? remapId(x.albumId, albumMap) : null; }); db[userId].albums.forEach((x: AlbumWithRefsWithId) => { x.tagIds = x.tagIds.map((id: number) => remapId(id, tagMap)); x.artistIds = x.artistIds.map((id: number) => remapId(id, artistMap)); x.trackIds = x.trackIds.map((id: number) => remapId(id, trackMap)); }); db[userId].artists.forEach((x: ArtistWithRefsWithId) => { x.tagIds = x.tagIds.map((id: number) => remapId(id, tagMap)); x.albumIds = x.albumIds.map((id: number) => remapId(id, albumMap)); x.trackIds = x.trackIds.map((id: number) => remapId(id, trackMap)); }); db[userId].tags.forEach((x: TagWithRefsWithId) => { x.parentId = x.parentId ? remapId(x.parentId, tagMap) : null; }); } return db; } // Transform all referenced IDs in an action given a mapping. function transformActionIDs(action: DBAction, mappings: IDMappings, rng: any) { let r: DBAction = _.cloneDeep(action); let doMap = (id: number, mapping: Record) => { if (id in mapping) { return mapping[id]; } // Number is not in the map. Generate something that will be nonexistent // for sure. let highest = 0; for (const [_, value] of Object.entries(mapping)) { highest = Math.max(value, highest); } let r = highest + 1 + Math.floor(rng() * 100); return r; } switch (r.type) { case DBActionType.CreateTrack: { let track = r.payload as TrackWithRefsWithId; track.tagIds.forEach((id: number) => doMap(id, mappings.tags)); track.artistIds.forEach((id: number) => doMap(id, mappings.artists)); track.albumId = track.albumId ? doMap(track.albumId, mappings.albums) : null; break; } case DBActionType.CreateArtist: { let artist = r.payload as ArtistWithRefsWithId; artist.tagIds.forEach((id: number) => doMap(id, mappings.tags)); artist.albumIds.forEach((id: number) => doMap(id, mappings.albums)); artist.trackIds.forEach((id: number) => doMap(id, mappings.tracks)); break; } case DBActionType.CreateAlbum: { let album = r.payload as AlbumWithRefsWithId; album.tagIds.forEach((id: number) => doMap(id, mappings.tags)); album.artistIds.forEach((id: number) => doMap(id, mappings.artists)); album.trackIds.forEach((id: number) => doMap(id, mappings.tracks)); break; } case DBActionType.CreateTag: { let tag = r.payload as TagWithRefsWithId; tag.parentId = tag.parentId ? doMap(tag.parentId, mappings.tags) : null; break; } case DBActionType.DeleteTrack: case DBActionType.DeleteArtist: case DBActionType.DeleteAlbum: case DBActionType.DeleteTag: { let keys = { [DBActionType.DeleteTrack]: 'tracks', [DBActionType.DeleteArtist]: 'artists', [DBActionType.DeleteAlbum]: 'albums', [DBActionType.DeleteTag]: 'tags', } r.payload = (mappings as any)[keys[r.type]][r.payload]; break; } } return r; } // This test is designed to find corner-cases in the back-end API implementation. // Operations are performed on the API while also being performed on a simplified // reference model. // The database is compared after each operation by exporting it through the export // endpoint. // This way, the import, export and resource operations are all thoroughly tested // in a random way, assuming that the reference model is the "gold standard". describe('Randomized model-based DB back-end tests', () => { it('all succeed', async done => { // Get a handle to perform API requests. let req = await init(); // Keep a trace of the actions that have been performed, for debugging purposes. let actionTrace: DBAction[] = []; // Seed the random number generator so that the test run can be reproduced. let seed: string = process.env.TEST_RANDOM_SEED || Math.random().toFixed(5).toString(); console.log(`Test random seed: '${seed}'`) // Keep track of database states. let refState: ReferenceDatabase | undefined = undefined; let realState: ReferenceDatabase | undefined = undefined; // As we perform operations, the real DB and reference model may assign // different new IDs to new objects. We need to maintain mappings in order // to keep the operations consistent (e.g. when we make references to) // existing objects along the way. let idMappingsRefToReal: IDMappings | undefined = undefined; try { // Create a reference DB. let refDB: ReferenceDatabase = _.cloneDeep(sampleDB); // Prime the real DB // First, create a user and log in. await helpers.createUser(req, "someone@email.com", "password1A!", 200); await helpers.login(req, "someone@email.com", "password1A!", 200); // Import the starting DB. let importResponse: DBImportResponse = (await helpers.importDB(req, refDB[1])).body; idMappingsRefToReal = importResponse; // Check that we are starting from an equal situation refState = refDB; realState = { [1]: (await helpers.getExport(req)).body, }; expect(normalizeDB(realState)).to.deep.equal(normalizeDB(refState)); // Create a random number generator to use throughout the test. let rng = seedrandom(seed); // This distribution determines the kind of random actions that will // be generated. let dist: RandomDBActionDistribution = { type: new Map([ [DBActionType.CreateTrack, 0.0625], [DBActionType.CreateArtist, 0.0625], [DBActionType.CreateAlbum, 0.0625], [DBActionType.CreateTag, 0.0625], [DBActionType.PutTrack, 0.0625], [DBActionType.PutArtist, 0.0625], [DBActionType.PutAlbum, 0.0625], [DBActionType.PutTag, 0.0625], [DBActionType.PatchTrack, 0.0625], [DBActionType.PatchArtist, 0.0625], [DBActionType.PatchAlbum, 0.0625], [DBActionType.PatchTag, 0.0625], [DBActionType.DeleteTrack, 0.0625], [DBActionType.DeleteArtist, 0.0625], [DBActionType.DeleteAlbum, 0.0625], [DBActionType.DeleteTag, 0.0625], ]), userId: new Map([[1, 1.0]]), createTrackParams: { linkAlbum: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, }, createAlbumParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, }, createArtistParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkAlbums: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, }, createTagParams: { linkParent: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), }, putTrackParams: { linkAlbum: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, validId: new Map([[false, 0.3], [true, 0.7]]), }, putAlbumParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, validId: new Map([[false, 0.3], [true, 0.7]]), }, putArtistParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkAlbums: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, validId: new Map([[false, 0.3], [true, 0.7]]), }, putTagParams: { linkParent: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), validId: new Map([[false, 0.3], [true, 0.7]]), }, patchTrackParams: { linkAlbum: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, replaceName: new Map([[false, 0.5], [true, 0.5]]), replaceTags: new Map([[false, 0.5], [true, 0.5]]), replaceAlbum: new Map([[false, 0.5], [true, 0.5]]), replaceArtists: new Map([[false, 0.5], [true, 0.5]]), validId: new Map([[false, 0.3], [true, 0.7]]), }, patchAlbumParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkArtists: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, replaceName: new Map([[false, 0.5], [true, 0.5]]), replaceTags: new Map([[false, 0.5], [true, 0.5]]), replaceTracks: new Map([[false, 0.5], [true, 0.5]]), replaceArtists: new Map([[false, 0.5], [true, 0.5]]), validId: new Map([[false, 0.3], [true, 0.7]]), }, patchArtistParams: { linkTracks: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkTags: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, linkAlbums: { numValid: new Map([[0, 1.0]]), numInvalid: new Map([[0, 0.9], [1, 0.05], [2, 0.05]]), }, replaceName: new Map([[false, 0.5], [true, 0.5]]), replaceTags: new Map([[false, 0.5], [true, 0.5]]), replaceTracks: new Map([[false, 0.5], [true, 0.5]]), replaceAlbums: new Map([[false, 0.5], [true, 0.5]]), validId: new Map([[false, 0.3], [true, 0.7]]), }, patchTagParams: { linkParent: new Map([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]), replaceName: new Map([[false, 0.5], [true, 0.5]]), replaceParent: new Map([[false, 0.5], [true, 0.5]]), validId: new Map([[false, 0.3], [true, 0.7]]), }, deleteTrackParams: { validId: new Map([[false, 0.2], [true, 0.8]]) }, deleteArtistParams: { validId: new Map([[false, 0.2], [true, 0.8]]) }, deleteAlbumParams: { validId: new Map([[false, 0.2], [true, 0.8]]) }, deleteTagParams: { validId: new Map([[false, 0.2], [true, 0.8]]) }, } // Loop to generate and execute a bunch of random actions. for (let i = 0; i < 100; i++) { // Generate an action (based on the reference DB) let refAction = randomDBAction( refDB, rng, dist ); actionTrace.push(refAction); console.log("Testing action: ", refAction); // Transform any referenced IDs using the ID mapping let realAction = transformActionIDs(refAction, idMappingsRefToReal, rng); // Apply the action to the real and reference DB. let { response: refResponse, status: refStatus } = applyReferenceDBAction(refAction, refDB); let { response: realResponse, status: realStatus } = await applyRealDBAction(realAction, req); // If this was an object creation action, we need to update the mappings. if (refStatus === 200 && realStatus === 200) { switch (refAction.type) { case DBActionType.CreateTrack: { idMappingsRefToReal.tracks[refResponse.id] = realResponse.id; break; } case DBActionType.CreateArtist: { idMappingsRefToReal.artists[refResponse.id] = realResponse.id; break; } case DBActionType.CreateAlbum: { idMappingsRefToReal.albums[refResponse.id] = realResponse.id; break; } case DBActionType.CreateTag: { idMappingsRefToReal.tags[refResponse.id] = realResponse.id; break; } default: { break; } } } // Compare the response and status. expect(realStatus).to.equal(refStatus); expect(normalizeResponse(realResponse)).to.deep.equal(normalizeResponse(refResponse)); // Compare the database state after the action. let newRefState = _.cloneDeep(refDB); let newRealState = { [1]: (await helpers.getExport(req)).body, }; expect(normalizeDB(newRealState)).to.deep.equal(normalizeDB(newRefState)); realState = newRealState; refState = newRefState; } } catch (e) { // When catching a comparison error, add and dump various states to files for debugging. e.actionTrace = actionTrace; e.startingDB = normalizeDB(sampleDB); e.lastRefDB = refState; e.lastRealDB = realState; e.testSeed = seed; e.idMappingsRefToReal = idMappingsRefToReal; if (e.actual && e.expected) { let basename = tmp.tmpNameSync(); e.actualDump = basename + "_actual"; e.expectedDump = basename + "_expected"; e.actionTraceDump = basename + "_actiontrace"; e.startingDBDump = basename + "_startingDB"; e.lastRefDBDump = basename + "_lastRefDB"; e.lastRealDBDump = basename + "_lastRealDB"; e.idMappingsRefToRealDump = basename + "_idMappings"; fs.writeFileSync(e.actualDump, stringify(e.actual, { space: ' ' })); fs.writeFileSync(e.expectedDump, stringify(e.expected, { space: ' ' })); fs.writeFileSync(e.actionTraceDump, stringify(e.actionTrace, { space: ' ' })); fs.writeFileSync(e.startingDBDump, stringify(e.startingDB, { space: ' ' })); fs.writeFileSync(e.lastRefDBDump, stringify(e.lastRefDB, { space: ' ' })); fs.writeFileSync(e.lastRealDBDump, stringify(e.lastRealDB, { space: ' ' })); fs.writeFileSync(e.idMappingsRefToRealDump, stringify(e.idMappingsRefToReal, { space: ' ' })); console.log( "A comparison error occurred. Wrote compared values to temporary files for debugging:\n" + ` Actual value: ${e.actualDump}\n` + ` Expected value: ${e.actualDump}\n` + ` DB action trace: ${e.actionTraceDump}\n` + ` Starting DB: ${e.startingDBDump}\n` + ` Reference DB before last action: ${e.lastRefDBDump}\n` + ` Real DB before last action: ${e.lastRealDBDump}\n` + ` ID Mappings from ref to real DB: ${e.idMappingsRefToRealDump}\n` + ` TEST_RANDOM_SEED: ${seed}` ); } throw e; } finally { req.close(); done(); } }); });