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
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(); |
|
} |
|
}); |
|
}); |