Skip to content

Commit d069c04

Browse files
Merge pull request #57 from MaddyGuthridge/maddy-test-migrations
Add test cases for data migration
2 parents fa3b685 + f33244d commit d069c04

File tree

9 files changed

+213
-2
lines changed

9 files changed

+213
-2
lines changed

package-lock.json

Lines changed: 41 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
"spinnies": "^0.5.1",
3434
"superstruct": "^2.0.2",
3535
"tippy.js": "^6.3.7",
36-
"validator": "^13.12.0"
36+
"validator": "^13.12.0",
37+
"yauzl": "^3.2.0"
3738
},
3839
"devDependencies": {
3940
"@eslint/js": "^9.11.1",
@@ -51,6 +52,7 @@
5152
"@types/semver": "^7.5.8",
5253
"@types/spinnies": "^0.5.3",
5354
"@types/validator": "^13.12.0",
55+
"@types/yauzl": "^2.10.3",
5456
"asciinema-player": "github:MaddyGuthridge/asciinema-player",
5557
"chalk": "^5.3.0",
5658
"cross-fetch": "^4.0.0",

src/lib/server/zip.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import yauzl, { Entry } from 'yauzl';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
/** Wrapper around `yauzl` to keep the callback hell contained */
6+
export function unzip(zipFile: string, destination: string): Promise<void> {
7+
return new Promise((resolve, reject) => {
8+
// Yoinked from documentation
9+
yauzl.open(zipFile, { lazyEntries: true }, (err, zipfile) => {
10+
if (err) {
11+
// TypeScript says this is an `Error` :/
12+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
13+
reject(err);
14+
}
15+
zipfile.readEntry();
16+
zipfile.on('entry', (entry: Entry) => {
17+
if (entry.fileName.endsWith('/')) {
18+
// Directory file names end with '/'.
19+
// Note that entries for directories themselves are optional.
20+
// An entry's fileName implicitly requires its parent directories to exist.
21+
zipfile.readEntry();
22+
} else {
23+
// file entry
24+
const outputFile = path.join(destination, entry.fileName);
25+
// mkdir -p {outputFile.parent}
26+
fs.mkdir(path.dirname(outputFile), { recursive: true }, (err) => {
27+
if (err) {
28+
reject(err);
29+
}
30+
// Make output stream
31+
// Since the `decodeStrings` option is on-by-default, there is no need to worry about
32+
// maliciously-crafted `entry.fileName` values trying to escape the destination path
33+
// (eg `../../escaped`).
34+
const output = fs.createWriteStream(outputFile)
35+
// Read file into its output location
36+
zipfile.openReadStream(entry, (err, readStream) => {
37+
if (err) {
38+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
39+
reject(err);
40+
}
41+
// No idea why this is giving a warning -- TypeScript says it returns `void`, not `any`
42+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
43+
readStream.on('end', () => zipfile.readEntry());
44+
readStream.pipe(output);
45+
});
46+
});
47+
}
48+
});
49+
// Once the end of the stream is reached, resolve the promise.
50+
zipfile.on('close', () => {
51+
resolve();
52+
});
53+
});
54+
});
55+
}

tests/backend/migrations/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Migrations
2+
3+
These test cases are used to ensure that data migrations continue to work
4+
correctly.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import api from '$endpoints';
2+
import { getDataDir, getPrivateDataDir } from '$lib/server/data/dataDir';
3+
import { unzip } from '$lib/server/zip';
4+
import { rimraf } from 'rimraf';
5+
6+
/** Extract the given zip file into the data dir, then refresh the data to trigger a migration */
7+
export async function migrateDataFromZip(zipFile: string) {
8+
await rimraf(getDataDir());
9+
await unzip(zipFile, getDataDir());
10+
await api().debug.dataRefresh();
11+
}
12+
13+
/**
14+
* Extract the given zip file into the private data dir, then refresh the data to trigger a
15+
* migration.
16+
*/
17+
export async function migratePrivateDataFromZip(zipFile: string) {
18+
await rimraf(getPrivateDataDir());
19+
await unzip(zipFile, getPrivateDataDir());
20+
await api().debug.dataRefresh();
21+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Migrations / v0.6.6
2+
3+
Migration test case for `v0.6.6`.
4+
5+
## Data
6+
7+
Overall structure:
8+
9+
* `group`
10+
* `listed`
11+
* `unlisted`
12+
13+
Of note:
14+
15+
* `listed` is a listed child of `group`
16+
* `unlisted` is not listed as a child of `group`
17+
* `listed` has a URL of `https://example.com`
18+
* `listed` and `unlisted` link to each other
19+
20+
## Private data
21+
22+
* Username: `maddy`
23+
* Password: `Maddy123#`
3 KB
Binary file not shown.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ApiClient } from '$endpoints';
2+
import { beforeEach, describe, expect, it } from 'vitest';
3+
import { setup } from '../../helpers';
4+
import { migrateDataFromZip, migratePrivateDataFromZip } from '../migration';
5+
import path from 'path';
6+
import itemId from '$lib/itemId';
7+
8+
let api: ApiClient;
9+
10+
beforeEach(async () => {
11+
api = (await setup()).api;
12+
});
13+
14+
describe('Public data', () => {
15+
beforeEach(async () => {
16+
await migrateDataFromZip(path.join(__dirname, 'data.zip'));
17+
});
18+
19+
it('Correctly creates all items', async () => {
20+
await expect(api.item(itemId.ROOT).info.get()).resolves.toMatchObject({
21+
children: ['group']
22+
});
23+
24+
await expect(api.item(itemId.fromStr('/group')).info.get()).resolves.toMatchObject({
25+
// Only listed child is listed
26+
children: ['listed']
27+
});
28+
29+
// Both children resolve
30+
await expect(api.item(itemId.fromStr('/group/listed')).info.get()).toResolve();
31+
await expect(api.item(itemId.fromStr('/group/unlisted')).info.get()).toResolve();
32+
});
33+
34+
it('Migrates URL sections correctly', async () => {
35+
await expect(api.item(itemId.fromStr('/group/listed')).info.get()).resolves.toMatchObject({
36+
sections: expect.arrayContaining([
37+
{
38+
type: 'site',
39+
url: 'https://example.com',
40+
// Uses default icon and label
41+
icon: null,
42+
label: null,
43+
},
44+
])
45+
});
46+
});
47+
48+
it('Migrates link sections correctly', async () => {
49+
await expect(api.item(itemId.fromStr('/group/listed')).info.get()).resolves.toMatchObject({
50+
sections: expect.arrayContaining([
51+
{
52+
type: 'links',
53+
label: 'See also',
54+
items: ['/group/unlisted'],
55+
style: 'card',
56+
},
57+
])
58+
});
59+
});
60+
});
61+
62+
it('Migrates private data correctly', async () => {
63+
await migratePrivateDataFromZip(path.join(__dirname, 'private_data.zip'));
64+
// We can still log in
65+
await expect(api.admin.auth.login('maddy', 'Maddy123#')).toResolve();
66+
});
697 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)