diff --git a/.vscode/launch.json b/.vscode/launch.json index 3238b8c..53deaf1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,8 @@ "request": "launch", "name": "Jasmine Tests with SQLite", "env": { - "MUDBASE_DB_CONFIG": "{\"client\": \"sqlite3\", \"connection\": \":memory:\"}" + "MUDBASE_DB_CONFIG": "{\"client\": \"sqlite3\", \"connection\": \":memory:\"}", + "TEST_RANDOM_SEED": "0.70810" }, "program": "${workspaceFolder}/server/node_modules/jasmine-ts/lib/index", "args": [ diff --git a/client/src/api/endpoints/data.ts b/client/src/api/endpoints/data.ts index 4e31874..ad79b3b 100644 --- a/client/src/api/endpoints/data.ts +++ b/client/src/api/endpoints/data.ts @@ -23,7 +23,13 @@ export type DBExportResponse = DBDataFormat; // Fully replace the user's database by an import (POST). export const DBImportEndpoint = "/import"; export type DBImportRequest = DBDataFormat; -export type DBImportResponse = void; +export interface IDMappings { + tracks: Record, + albums: Record, + artists: Record, + tags: Record, +} +export type DBImportResponse = IDMappings; // Returns the IDs mapped during import. export const checkDBImportRequest: (v: any) => boolean = (v: any) => { return 'tracks' in v && 'albums' in v && diff --git a/server/db/Data.ts b/server/db/Data.ts index e375b8f..f4291f9 100644 --- a/server/db/Data.ts +++ b/server/db/Data.ts @@ -1,5 +1,5 @@ import Knex from "knex"; -import { TrackWithRefsWithId, AlbumWithRefsWithId, ArtistWithRefsWithId, TagWithRefsWithId, TrackWithRefs, AlbumBaseWithRefs } from "../../client/src/api/api"; +import { TrackWithRefsWithId, AlbumWithRefsWithId, ArtistWithRefsWithId, TagWithRefsWithId, TrackWithRefs, AlbumBaseWithRefs, DBImportResponse, IDMappings } from "../../client/src/api/api"; import * as api from '../../client/src/api/api'; import asJson from "../lib/asJson"; import { createArtist } from "./Artist"; @@ -155,44 +155,48 @@ export async function exportDB(userId: number, knex: Knex): Promise { +export async function importDB(userId: number, db: api.DBDataFormat, knex: Knex): Promise { // Store the ID mappings in this record. - let tagIdMaps: Record = {}; // Maps import ID to db ID - let artistIdMaps: Record = {}; // Maps import ID to db ID - let albumIdMaps: Record = {}; // Maps import ID to db ID - let trackIdMaps: Record = {}; // Maps import ID to db ID + let maps: IDMappings = { + tracks: {}, + artists: {}, + albums: {}, + tags: {}, + } // Insert items one by one, remapping the IDs as we go. for(const tag of db.tags) { let _tag = { ..._.omit(tag, 'id'), - parentId: tag.parentId ? tagIdMaps[tag.parentId] : null, + parentId: tag.parentId ? maps.tags[tag.parentId] : null, } - tagIdMaps[tag.id] = await createTag(userId, _tag, knex); + maps.tags[tag.id] = await createTag(userId, _tag, knex); } for(const artist of db.artists) { - artistIdMaps[artist.id] = await createArtist(userId, { + maps.artists[artist.id] = await createArtist(userId, { ..._.omit(artist, 'id'), - tagIds: artist.tagIds.map((id: number) => tagIdMaps[id]), + tagIds: artist.tagIds.map((id: number) => maps.tags[id]), trackIds: [], albumIds: [], }, knex); } for(const album of db.albums) { - albumIdMaps[album.id] = await createAlbum(userId, { + maps.albums[album.id] = await createAlbum(userId, { ..._.omit(album, 'id'), - tagIds: album.tagIds.map((id: number) => tagIdMaps[id]), - artistIds: album.artistIds.map((id: number) => artistIdMaps[id]), + tagIds: album.tagIds.map((id: number) => maps.tags[id]), + artistIds: album.artistIds.map((id: number) => maps.artists[id]), trackIds: [], }, knex); } for(const track of db.tracks) { - trackIdMaps[track.id] = await createTrack(userId, { + maps.tracks[track.id] = await createTrack(userId, { ..._.omit(track, 'id'), - tagIds: track.tagIds.map((id: number) => tagIdMaps[id]), - artistIds: track.artistIds.map((id: number) => artistIdMaps[id]), - albumId: track.albumId ? albumIdMaps[track.albumId] : null, + tagIds: track.tagIds.map((id: number) => maps.tags[id]), + artistIds: track.artistIds.map((id: number) => maps.artists[id]), + albumId: track.albumId ? maps.albums[track.albumId] : null, }, knex); } + + return maps; } export async function wipeDB(userId: number, knex: Knex) { diff --git a/server/db/Track.ts b/server/db/Track.ts index 85419a6..5c1a146 100644 --- a/server/db/Track.ts +++ b/server/db/Track.ts @@ -276,6 +276,12 @@ export async function modifyTrack(userId: number, trackId: number, track: TrackB export async function deleteTrack(userId: number, trackId: number, knex: Knex): Promise { await knex.transaction(async (trx) => { + // FIXME remove + + let tracks = await trx.select('id', 'name') + .from('tracks'); + console.log("All tracks:", tracks); + // Start by retrieving the track itself for sanity. const confirmTrackId: number | undefined = await trx.select('id') diff --git a/server/endpoints/Data.ts b/server/endpoints/Data.ts index cd25669..37b81d7 100644 --- a/server/endpoints/Data.ts +++ b/server/endpoints/Data.ts @@ -31,8 +31,8 @@ export const DBImport: EndpointHandler = async (req: any, res: any, knex: Knex) console.log("User ", userId, ": Import DB "); try { - await importDB(userId, reqObject, knex) - res.status(200).send(); + let mappings = await importDB(userId, reqObject, knex) + res.status(200).send(mappings); } catch (e) { handleErrorsInEndpoint(e); diff --git a/server/test/integration/flows/ResourceFlow.ts b/server/test/integration/flows/ResourceFlow.ts index 6541002..5338a6b 100644 --- a/server/test/integration/flows/ResourceFlow.ts +++ b/server/test/integration/flows/ResourceFlow.ts @@ -7,7 +7,7 @@ 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, TagWithRefsWithId, TrackWithRefs, TrackWithRefsWithId } from '../../../../client/src/api/api'; +import { AlbumWithRefsWithId, Artist, ArtistWithRefsWithId, DBImportResponse, IDMappings, TagWithRefsWithId, TrackWithRefs, TrackWithRefsWithId } from '../../../../client/src/api/api'; import sampleDB from '../sampleDB'; let stringify = require('json-stringify-deterministic'); @@ -69,7 +69,7 @@ function normalizeDB(oldDb: ReferenceDatabase) { } for (const userId in db) { // First remap the IDs only, ignoring references - db[userId].tracks.forEach((x: TrackWithRefsWithId) => { console.log("X:", x); x.id = remapId(x.id, trackMap); }); + 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); }) @@ -99,16 +99,61 @@ function normalizeDB(oldDb: ReferenceDatabase) { 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); + } + return highest + 1 + Math.floor(rng() * 100); + } + + 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.DeleteTrack: { + r.payload = mappings.tracks[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}'`) try { - // Create a reference DB + // Create a reference DB. let refDB: ReferenceDatabase = _.cloneDeep(sampleDB); // Prime the real DB @@ -116,7 +161,13 @@ describe('Randomized model-based DB back-end tests', () => { await helpers.createUser(req, "someone@email.com", "password1A!", 200); await helpers.login(req, "someone@email.com", "password1A!", 200); // Import the starting DB. - await helpers.importDB(req, refDB[1]); + let importResponse: DBImportResponse = (await helpers.importDB(req, refDB[1])).body; + + // 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 = importResponse; // Check that we are starting from an equal situation let refState = normalizeDB(refDB); @@ -125,8 +176,11 @@ describe('Randomized model-based DB back-end tests', () => { }); expect(realState).to.deep.equal(refState); - // Start doing some random changes, checking the state after each step. + // 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.7], @@ -149,20 +203,35 @@ describe('Randomized model-based DB back-end tests', () => { } } - for (let i = 0; i < 30; i++) { - let action = randomDBAction( + // 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(action); - console.log("Testing action: ", action); - let { response: refResponse, status: refStatus } = applyReferenceDBAction(action, refDB); - let { response: realResponse, status: realStatus } = await applyRealDBAction(action, req); + 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 (refAction.type === DBActionType.CreateTrack) { + let refId = refResponse.id; + let realId = realResponse.id; + idMappingsRefToReal.tracks[refId] = realId; + } // Compare the response and status. - expect(normalizeResponse(realResponse)).to.deep.equal(normalizeResponse(refResponse)); expect(realStatus).to.equal(refStatus); + expect(normalizeResponse(realResponse)).to.deep.equal(normalizeResponse(refResponse)); // Compare the database state after the action. let refState = normalizeDB(refDB); @@ -191,7 +260,8 @@ describe('Randomized model-based DB back-end tests', () => { + ` actual: ${e.actualDump}\n` + ` expected: ${e.expectedDump}\n` + ` DB action trace: ${e.actionTraceDump}\n` - + ` Starting DB: ${e.startingDBDump}` + + ` Starting DB: ${e.startingDBDump}\n` + + ` TEST_RANDOM_SEED: ${seed}` ); } throw e; diff --git a/server/test/integration/helpers.ts b/server/test/integration/helpers.ts index 5f8d234..6037aa1 100644 --- a/server/test/integration/helpers.ts +++ b/server/test/integration/helpers.ts @@ -1,5 +1,5 @@ import { sha512 } from "js-sha512"; -import { DBDataFormat, IntegrationImpl } from "../../../client/src/api/api"; +import { DBDataFormat, DBImportResponse, IntegrationImpl } from "../../../client/src/api/api"; import { ReferenceDatabase } from "../reference_model/DBReferenceModel"; let chai = require('chai'); diff --git a/server/test/reference_model/DBReferenceModel.ts b/server/test/reference_model/DBReferenceModel.ts index 703232f..6f3224a 100644 --- a/server/test/reference_model/DBReferenceModel.ts +++ b/server/test/reference_model/DBReferenceModel.ts @@ -34,8 +34,10 @@ function ensureInSet(n: number, s: number[]) { function ensureLinked(fromObjects: number[], fromObjectsType: ObjectsType, toId: number, toObjectsType: ObjectsType, data: DBDataFormat) { if (toObjectsType === 'tracks') { - fromObjects.forEach((fromId: number) => ensureInSet(toId, - (data[fromObjectsType][fromId] as AlbumWithRefsWithId | ArtistWithRefsWithId).trackIds)) + fromObjects.forEach((fromId: number) => { + let fromObject = (data[fromObjectsType] as any).find((o: any) => o.id === fromId); + ensureInSet(toId, (fromObject as AlbumWithRefsWithId | ArtistWithRefsWithId).trackIds) + }) } } diff --git a/server/test/reference_model/randomGen.ts b/server/test/reference_model/randomGen.ts index ada98ac..d8309a9 100644 --- a/server/test/reference_model/randomGen.ts +++ b/server/test/reference_model/randomGen.ts @@ -6,7 +6,7 @@ import { DBErrorKind, isDBError } from "../../endpoints/types"; export enum DBActionType { CreateTrack = 0, - DeleteTrack, + DeleteTrack, } export interface DBAction { @@ -90,10 +90,12 @@ export function applyReferenceDBAction( let response: any = undefined; let status: number = 0; + console.log('ref action:', action); try { switch (action.type) { case DBActionType.CreateTrack: { response = createTrack(action.userId, action.payload, db); + console.log("create track ref response", response) status = 200; break; } @@ -104,12 +106,14 @@ export function applyReferenceDBAction( break; } } - } catch(e) { - if(isDBError(e)) { - if(e.kind === DBErrorKind.ResourceNotFound) { + } catch (e) { + if (isDBError(e)) { + if (e.kind === DBErrorKind.ResourceNotFound) { status = 404; response = {}; } + } else { + throw e; } } @@ -175,8 +179,8 @@ export function randomDBAction( return { type: type, payload: applyDistribution(distribution.deleteTrackParams.validTrack, randomNumGen) ? - Math.floor(Math.random() * db[userId].tracks.length) + 1 : - Math.floor(Math.random() * db[userId].tracks.length) + 1 + db[userId].tracks.length, + Math.floor(randomNumGen() * db[userId].tracks.length) + 1 : + Math.floor(randomNumGen() * db[userId].tracks.length) + 1 + db[userId].tracks.length, userId: userId, } } @@ -200,7 +204,7 @@ export function createRandomTrack( let validArtists: number[] = pickNFromArray(allValidArtistIds, randomNumGen, applyDistribution(trackDist.linkArtists.numValid, randomNumGen)); let invalidArtists: number[] = []; for (let i = 0; i < applyDistribution(trackDist.linkArtists.numInvalid, randomNumGen); i++) { - invalidArtists.push(Math.round(Math.random() * 100) + allValidArtistIds.length); + invalidArtists.push(Math.round(randomNumGen() * 100) + allValidArtistIds.length); } return [...validArtists, ...invalidArtists]; })(); @@ -209,7 +213,7 @@ export function createRandomTrack( let validTags: number[] = pickNFromArray(allValidTagIds, randomNumGen, applyDistribution(trackDist.linkTags.numValid, randomNumGen)); let invalidTags: number[] = []; for (let i = 0; i < applyDistribution(trackDist.linkTags.numInvalid, randomNumGen); i++) { - invalidTags.push(Math.round(Math.random() * 100) + allValidTagIds.length); + invalidTags.push(Math.round(randomNumGen() * 100) + allValidTagIds.length); } return [...validTags, ...invalidTags]; })();