You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

488 lines
23 KiB

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 = '<redacted>';
}
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<number, number>,
highestId: number,
};
let trackMap: IDMap = { map: new Map<number, number>(), highestId: 0 };
let albumMap: IDMap = { map: new Map<number, number>(), highestId: 0 };
let artistMap: IDMap = { map: new Map<number, number>(), highestId: 0 };
let tagMap: IDMap = { map: new Map<number, number>(), 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<number, number>) => {
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<boolean | 'nonexistent', number>([[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<boolean | 'nonexistent', number>([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]),
},
putTrackParams: {
linkAlbum: new Map<boolean | 'nonexistent', number>([[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<boolean | 'nonexistent', number>([[false, 0.45], [true, 0.45], ['nonexistent', 0.1]]),
validId: new Map([[false, 0.3], [true, 0.7]]),
},
patchTrackParams: {
linkAlbum: new Map<boolean | 'nonexistent', number>([[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<boolean | 'nonexistent', number>([[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();
}
});
});