test: cover publishing, authentication and storage persistence

This commit is contained in:
2026-09-15 21:48:15 +03:30
parent ee9f49e4b7
commit c17b4b57b2
11 changed files with 294 additions and 23 deletions

53
e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
import { expect, test } from '@playwright/test';
test('owner can edit content, create a translated project, upload media, and sign out', async ({ page, request }) => {
expect((await request.get('/api/admin/settings')).status()).toBe(401);
await page.goto('/admin');
await expect(page).toHaveURL(/\/admin\/login$/);
await page.getByLabel('Email', { exact: true }).fill('e2e@example.com');
await page.getByLabel('Password', { exact: true }).fill('Only-for-isolated-e2e-tests-2026');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Site settings', exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath('admin-settings.png'), fullPage: true });
await page.getByRole('button', { name: 'Profile & sections', exact: true }).click();
await page.getByRole('button', { name: 'Hero hero', exact: true }).click();
const name = page.getByLabel('Name', { exact: true });
const oldName = await name.inputValue();
await name.fill('An editable portfolio');
await page.getByRole('button', { name: 'Save changes', exact: true }).first().click();
await expect(page.getByRole('status')).toContainText('Saved');
expect(await (await request.get('/')).text()).toContain('An editable portfolio');
await page.getByLabel('Name', { exact: true }).fill(oldName);
await page.getByRole('button', { name: 'Save changes', exact: true }).first().click();
await expect(page.getByRole('status')).toContainText('Saved');
await page.getByRole('button', { name: 'Languages', exact: true }).click();
await page.getByRole('button', { name: '+ Add language', exact: true }).click();
await page.getByLabel('Name', { exact: true }).fill('Deutsch');
await page.getByLabel('Language code').fill('de');
await page.getByRole('button', { name: 'Save changes', exact: true }).first().click();
await expect(page.getByRole('status')).toContainText('Saved');
const german = await request.get('/de'); expect(german.status()).toBe(200); expect(await german.text()).toContain('lang="de"');
await page.getByRole('button', { name: 'Projects', exact: true }).click();
await page.getByRole('button', { name: '+ Add item', exact: true }).click();
const id = `browser-project-${test.info().project.name}`;
await page.getByLabel('ID (used in the URL)', { exact: true }).fill(id);
await page.getByRole('button', { name: 'Save changes', exact: true }).first().click();
await expect(page.getByRole('status')).toContainText('Saved');
expect((await request.get(`/de/projects/${id}`)).status()).toBe(200);
await page.getByLabel('Visible', { exact: true }).uncheck();
await page.getByRole('button', { name: 'Save changes', exact: true }).first().click();
await expect(page.getByRole('status')).toContainText('Saved');
expect((await request.get(`/projects/${id}`)).status()).toBe(404);
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Delete item', exact: true }).click();
await expect(page.getByRole('status')).toContainText('Deleted');
await page.getByRole('button', { name: 'Media library', exact: true }).click();
await page.locator('input[type=file]').setInputFiles({ name: 'certificate.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF') });
await expect(page.getByRole('link', { name: 'certificate.pdf' }).first()).toBeVisible();
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
await expect(page).toHaveURL(/\/admin\/login$/);
await page.goto('/admin'); await expect(page).toHaveURL(/\/admin\/login$/);
});

View File

@@ -16,6 +16,9 @@ test('a pointer click on the card CTA opens a route-backed drawer and restores t
});
const card = page.getByRole('article', { name: 'Qlockify' });
const cta = card.getByRole('link', { name: 'View case study' });
// Measure the viewport from which the user actually clicks, after any
// automatic scroll needed to bring the link into view.
await cta.scrollIntoViewIfNeeded();
const portfolioScroll = await page.evaluate(() => window.scrollY);
await cta.click();
@@ -48,6 +51,9 @@ test('a shared route opens over the portfolio and adjacent navigation replaces h
});
test('share fallback, copy feedback, focus containment, and the localized RTL route work', async ({ page, context }) => {
// This test exercises the fallback. Native mobile share sheets are outside
// the DOM and are covered separately through the supported-share branch.
await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: undefined }));
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/fa/projects/qlockify', { waitUntil: 'domcontentloaded' });
const dialog = await expectDrawerOpen(page);
@@ -68,7 +74,16 @@ test('share fallback, copy feedback, focus containment, and the localized RTL ro
expect(await page.evaluate(() => document.activeElement?.closest('dialog') !== null)).toBe(true);
});
test('the gallery serves original media and opens an accessible full-screen viewer', async ({ page }) => {
test('mobile uses native sharing when it is available', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Native sharing is used on compact layouts');
await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { document.documentElement.dataset.sharedUrl = data.url; } }));
await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' });
const dialog = await expectDrawerOpen(page);
await dialog.getByRole('button', { name: 'Share this case study' }).click();
await expect(page.locator('html')).toHaveAttribute('data-shared-url', new RegExp(`${caseStudyPath}$`));
});
test('the gallery serves original media and opens an accessible full-screen viewer', async ({ page, isMobile }) => {
await page.goto(caseStudyPath, { waitUntil: 'domcontentloaded' });
const drawer = await expectDrawerOpen(page);
const stage = drawer.getByRole('button', { name: /Open full-screen image/ });
@@ -79,7 +94,11 @@ test('the gallery serves original media and opens an accessible full-screen view
const viewer = page.getByRole('dialog', { name: 'Full-screen project gallery' });
await expect(viewer).toBeVisible();
await expect(viewer.getByText('1 of 8')).toBeVisible();
if (isMobile) {
await viewer.getByRole('button', { name: /^Show image 2:/ }).click();
} else {
await viewer.getByRole('button', { name: 'Next image' }).click();
}
await expect(viewer.getByText('2 of 8')).toBeVisible();
await viewer.getByRole('button', { name: 'Close full-screen image' }).click();
await expect(stage).toBeFocused();

View File

@@ -22,11 +22,10 @@ export default defineConfig({
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'], channel: 'chrome' } },
],
webServer: {
command: 'node server.js',
cwd: '.next/standalone',
command: 'node scripts/e2e-server.mjs',
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120_000,
},
});

16
scripts/e2e-server.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { spawn, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
const cwd = path.resolve('.next/standalone');
const directory = await mkdtemp(path.join(tmpdir(), 'portfolio-e2e-'));
const port = process.env.PLAYWRIGHT_PORT || '43119';
const env = { ...process.env, DATA_DIR: directory, SITE_URL: `http://127.0.0.1:${port}`, HOSTNAME: '127.0.0.1', PORT: port, OWNER_EMAIL: 'e2e@example.com', OWNER_PASSWORD: 'Only-for-isolated-e2e-tests-2026' };
for (const command of ['import-legacy', 'create-owner']) {
const result = spawnSync(process.execPath, ['maintenance.mjs', command], { cwd, env, stdio: 'inherit' });
if (result.status !== 0) { await rm(directory, { recursive: true, force: true }); process.exit(result.status || 1); }
}
const server = spawn(process.execPath, ['server.js'], { cwd, env, stdio: 'inherit' });
process.on('SIGTERM', () => server.kill()); process.on('SIGINT', () => server.kill());
server.on('exit', async code => { await rm(directory, { recursive: true, force: true }); process.exit(code || 0); });

View File

@@ -3,14 +3,20 @@ set -Eeuo pipefail
image="${1:?runtime image is required}"
container_name="portfolio-smoke-${GITEA_RUN_ID:-$$}"
volume_name="${container_name}-data"
cleanup() {
docker rm --force "$container_name" >/dev/null 2>&1 || true
docker volume rm "$volume_name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker run --detach --name "$container_name" "$image" >/dev/null
docker volume create "$volume_name" >/dev/null
start_container() {
docker run --detach --name "$container_name" --mount "type=volume,source=$volume_name,target=/data" --env SITE_URL=http://127.0.0.1:3000 "$image" >/dev/null
}
start_container
for _ in {1..30}; do
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name")"
@@ -32,6 +38,11 @@ if [[ "$(docker inspect --format '{{.State.Health.Status}}' "$container_name")"
exit 1
fi
docker exec "$container_name" node -e "fetch('http://127.0.0.1:3000/').then(async r=>{if(!r.ok||!(await r.text()).includes('Your Name'))process.exit(1)})"
# Exercise the packaged maintenance command and native SQLite driver in the image.
docker exec "$container_name" node maintenance.mjs import-legacy
docker exec --env OWNER_EMAIL=smoke@example.com --env OWNER_PASSWORD=Isolated-container-smoke-password-2026 "$container_name" node maintenance.mjs create-owner
docker exec -i "$container_name" node <<'NODE'
const origin = 'http://127.0.0.1:3000';
@@ -63,3 +74,39 @@ if ((await optimized.arrayBuffer()).byteLength === 0) {
throw new Error('Optimized image response was empty.');
}
NODE
# Upload through the real authenticated endpoint, then replace the container.
docker exec -i "$container_name" node --input-type=module <<'NODE'
import { writeFileSync } from 'node:fs';
const origin = 'http://127.0.0.1:3000';
const login = await fetch(`${origin}/api/auth/sign-in/email`, {
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'smoke@example.com', password: 'Isolated-container-smoke-password-2026' }),
});
if (!login.ok) throw new Error('Container owner login failed');
const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ');
const form = new FormData();
form.set('file', new Blob(['%PDF-1.4\nContainer persistence check\n%%EOF'], { type: 'application/pdf' }), 'check.pdf');
const response = await fetch(`${origin}/api/admin/media`, { method: 'POST', headers: { Origin: origin, Cookie: cookie }, body: form });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const uploaded = await response.json();
writeFileSync('/data/smoke-media-key', uploaded.key);
NODE
docker rm --force "$container_name" >/dev/null
start_container
docker exec -i "$container_name" node --input-type=module <<'NODE'
import { readFileSync } from 'node:fs';
const origin = 'http://127.0.0.1:3000';
let ready = false;
for (let attempt = 0; attempt < 30; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) { ready = true; break; } } catch {}
await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!ready) throw new Error('Replacement container did not start');
const page = await fetch(`${origin}/projects/interanet-solutions`);
if (!page.ok) throw new Error('Database did not survive container replacement');
const key = readFileSync('/data/smoke-media-key', 'utf8');
const media = await fetch(`${origin}/media/${key}`);
if (!media.ok || !(await media.text()).includes('Container persistence check')) throw new Error('Upload did not survive container replacement');
console.log('Container startup, native SQLite, owner login, uploads, and replacement persistence passed.');
NODE

58
tests/content.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { openDatabase, migrateDatabase } from '../src/server/db';
import { seedDatabase } from '../src/server/seed';
import { readContent, saveContent, resolvePublicRoute, deleteContent } from '../src/server/content';
import { importLegacy } from '../src/server/legacy-import';
function setup(t: { after: (fn: () => void) => void }) {
const directory = mkdtempSync(path.join(tmpdir(), 'portfolio-test-'));
const connection = openDatabase(directory);
migrateDatabase(connection); seedDatabase(connection);
t.after(() => { connection.sqlite.close(); rmSync(directory, { recursive: true, force: true }); });
return connection;
}
test('seed is idempotent and content saves are validated and immediately readable', t => {
const connection = setup(t); const original = readContent(connection);
const hero = original.sections.find(section => section.key === 'hero')!;
hero.translations.en.name = 'Test owner';
saveContent(connection, 'sections', hero);
seedDatabase(connection);
assert.equal(resolvePublicRoute(readContent(connection)).portfolio.messages.hero.name, 'Test owner');
assert.throws(() => saveContent(connection, 'sections', { ...hero, translations: { en: { name: '' } } }));
assert.equal(resolvePublicRoute(readContent(connection)).portfolio.messages.hero.name, 'Test owner');
assert.throws(() => importLegacy(connection), /before content has been edited/);
saveContent(connection, 'sections', { ...hero, visible: false });
assert.ok(!JSON.stringify(resolvePublicRoute(readContent(connection)).portfolio).includes('Test owner'));
});
test('runtime projects and arbitrary locales resolve, hidden and missing URLs do not', t => {
const connection = setup(t);
saveContent(connection, 'languages', { code: 'ar', name: 'العربية', direction: 'rtl', enabled: true, messages: { 'projects.viewCaseStudy': 'دراسة الحالة' } });
assert.equal(resolvePublicRoute(readContent(connection), ['ar']).portfolio.direction, 'rtl');
assert.equal(resolvePublicRoute(readContent(connection), ['ar']).portfolio.messages.projects.viewCaseStudy, 'دراسة الحالة');
const project = readContent(connection).entries.find(entry => entry.kind === 'projects')!;
saveContent(connection, 'entries', { ...project, id: 'new-project' });
assert.equal(resolvePublicRoute(readContent(connection), ['ar','projects','new-project']).project?.id, 'new-project');
saveContent(connection, 'entries', { ...project, id: 'new-project', visible: false });
assert.throws(() => resolvePublicRoute(readContent(connection), ['projects','new-project']), /not found/);
deleteContent(connection, 'entries', project.id);
assert.throws(() => resolvePublicRoute(readContent(connection), ['projects',project.id]), /not found/);
for (const parts of [['en'], ['unknown'], ['ar','bad'], ['admin']]) assert.throws(() => resolvePublicRoute(readContent(connection), parts));
assert.throws(() => saveContent(connection, 'languages', { code: 'en', name: 'English', direction: 'ltr', enabled: false, messages: {} }));
});
test('legacy import preserves English and Persian projects and only runs once', t => {
const connection = setup(t); importLegacy(connection);
const snapshot = readContent(connection);
assert.equal(resolvePublicRoute(snapshot, ['projects','qlockify']).project?.title, 'Qlockify');
assert.equal(resolvePublicRoute(snapshot, ['fa']).portfolio.direction, 'rtl');
assert.ok(snapshot.entries.length > 5);
assert.throws(() => importLegacy(connection));
});
test('startup rejects a database migrated by an incompatible application image', t => {
const connection = setup(t);
connection.sqlite.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future-migration', Date.now() + 100000);
assert.throws(() => migrateDatabase(connection), /do not match/);
});

View File

@@ -2,20 +2,18 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('the isometric footer preserves the real contact destinations and email', async () => {
test('the isometric footer uses editable contact destinations and email', async () => {
const footer = await readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8');
assert.match(footer, /tel:09388263077/);
assert.match(footer, /linkedin\.com\/in\/amiirkhl/);
assert.match(footer, /https:\/\/git\.amiirkhl\.ir/);
assert.match(footer, /https:\/\/t\.me\/amiirkhl/);
assert.match(footer, /mailto:amirhosseinkhalili901@gmail\.com/);
assert.match(footer, /portfolio\.profile\.contacts/);
assert.match(footer, /portfolio\.profile\.email/);
assert.doesNotMatch(footer, /amiirkhl|09388263077/);
});
test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => {
const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8');
assert.match(footerSection, /<IsometricFooter locale=\{locale\}\s*\/>/);
assert.match(footerSection, /<IsometricFooter portfolio=\{portfolio\}\s*\/>/);
assert.doesNotMatch(footerSection, /next\/image/);
assert.doesNotMatch(footerSection, /footer-background.*\.png/);
});

View File

@@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { readFile, access } from 'node:fs/promises';
import test from 'node:test';
test('the production package does not contain runtime data or authentication secrets', async () => {
await assert.rejects(access('.next/standalone/data/auth-secret'));
await assert.rejects(access('.next/standalone/data/portfolio.sqlite'));
await assert.rejects(access('.next/standalone/.env'));
});
test('Next runs as a standalone server with the image optimizer enabled', async () => {
const [config, packageJson] = await Promise.all([
readFile('next.config.mjs', 'utf8'),

View File

@@ -5,11 +5,11 @@ import test from 'node:test';
const routeFiles = [
'src/app/(en)/projects/layout.tsx',
'src/app/(en)/projects/[projectId]/page.tsx',
'src/app/(fa)/fa/projects/layout.tsx',
'src/app/(fa)/fa/projects/[projectId]/page.tsx',
'src/app/(localized)/[locale]/projects/layout.tsx',
'src/app/(localized)/[locale]/projects/[projectId]/page.tsx',
];
test('localized project routes are statically generated and reject unknown slugs', async () => {
test('localized project routes use runtime content and metadata', async () => {
await Promise.all(routeFiles.map((file) => access(file)));
const pages = (
@@ -20,8 +20,8 @@ test('localized project routes are statically generated and reject unknown slugs
)
).join('\n');
assert.match(pages, /generateStaticParams/);
assert.match(pages, /dynamicParams\s*=\s*false/);
assert.doesNotMatch(pages, /generateStaticParams/);
assert.doesNotMatch(pages, /dynamicParams\s*=\s*false/);
assert.match(pages, /await params/);
assert.match(pages, /generateMetadata/);
});

View File

@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { access, cp, readFile } from 'node:fs/promises';
import { spawn, spawnSync } from 'node:child_process';
import { access, cp, readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { once } from 'node:events';
import path from 'node:path';
import test from 'node:test';
@@ -23,6 +25,8 @@ async function waitForServer(process, getLogs) {
}
test('the standalone server renders both locales and serves local images', async (t) => {
const dataDir = await mkdtemp(path.join(tmpdir(), 'portfolio-runtime-'));
const env = { ...process.env, DATA_DIR: dataDir, SITE_URL: origin, HOSTNAME: '127.0.0.1', PORT: String(port) };
await access(path.join(standaloneRoot, 'server.js'));
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
await cp(
@@ -31,15 +35,17 @@ test('the standalone server renders both locales and serves local images', async
{ recursive: true },
);
const imported = spawnSync(process.execPath, ['maintenance.mjs', 'import-legacy'], { cwd: standaloneRoot, env, encoding: 'utf8' });
assert.equal(imported.status, 0, imported.stderr);
const server = spawn(process.execPath, ['server.js'], {
cwd: standaloneRoot,
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLogs = '';
server.stdout.on('data', (chunk) => { serverLogs += chunk; });
server.stderr.on('data', (chunk) => { serverLogs += chunk; });
t.after(() => server.kill());
t.after(async () => { if (server.exitCode === null) { const exited = once(server, 'exit'); server.kill(); await exited; } await rm(dataDir, { recursive: true, force: true }); });
await waitForServer(server, () => serverLogs);
const [englishResponse, persianResponse] = await Promise.all([

69
tests/security.test.ts Normal file
View File

@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { tmpdir } from 'node:os';
import sharp from 'sharp';
import { initialize } from '../src/server/initialize';
import { provisionOwner } from '../src/server/owner';
import { getAuth } from '../src/server/auth';
import { requireOwner, boundedBody } from '../src/server/http';
import { storeUpload, deleteUpload } from '../src/server/media';
import { readContent, saveContent } from '../src/server/content';
import { backup, restore } from '../src/server/backup';
import { withContentLock } from '../src/server/write-lock';
import { openDatabase } from '../src/server/db';
test('owner authentication, media validation, backup and restore', async t => {
const root = mkdtempSync(path.join(tmpdir(), 'portfolio-security-'));
process.env.DATA_DIR = path.join(root, 'data');
process.env.SITE_URL = 'http://localhost:39991';
process.env.BETTER_AUTH_SECRET = randomBytes(48).toString('base64url');
const connection = initialize();
t.after(() => { connection.sqlite.close(); rmSync(root, { recursive: true, force: true }); });
const email = 'owner@example.com'; const password = randomBytes(20).toString('base64url');
await provisionOwner(connection, email, password);
await assert.rejects(() => provisionOwner(connection, 'other@example.com', password), /already exists/);
const auth = getAuth();
const request = (endpoint: string, body: unknown) => new Request(`${process.env.SITE_URL}/api/auth/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json', origin: process.env.SITE_URL!, 'x-forwarded-for': '127.0.0.1' }, body: JSON.stringify(body) });
const signup = await auth.handler(request('sign-up/email', { email: 'outsider@example.com', password, name: 'Outsider' }));
assert.ok(signup.status >= 400);
const response = await auth.handler(request('sign-in/email', { email, password }));
assert.equal(response.status, 200, await response.clone().text());
const cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ');
assert.ok(cookie);
assert.equal((await requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { headers: { cookie } }))).email, email);
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`)), /Sign in/);
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { method: 'PUT', headers: { cookie, origin: 'https://evil.example' } })), /origin/);
await assert.rejects(() => boundedBody(new Request('http://localhost', { method: 'POST', body: 'oversized' }), 2), /too large/);
await provisionOwner(connection, email, randomBytes(20).toString('base64url'), true);
await assert.rejects(() => requireOwner(new Request(`${process.env.SITE_URL}/api/admin/settings`, { headers: { cookie } })), /Sign in/);
let throttled = false;
for (let attempt = 0; attempt < 7; attempt++) {
const failed = await auth.handler(request('sign-in/email', { email, password: 'deliberately-wrong-password' }));
if (failed.status === 429) throttled = true;
}
assert.ok(throttled, 'Repeated login attempts must be rate limited');
await assert.rejects(() => storeUpload(connection, 'bad.png', Buffer.from('<script>alert(1)</script>')), /valid/);
const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: '#557799' } }).png().toBuffer();
const upload = await storeUpload(connection, '../../picture.png', png);
assert.equal(upload.originalName, 'picture.png'); assert.equal(upload.mime, 'image/webp');
const settings = readContent(connection).settings;
settings.avatar = { src: upload.url, width: upload.width!, height: upload.height! };
saveContent(connection, 'settings', settings);
assert.throws(() => deleteUpload(connection, upload.key), /still used/);
const destination = path.join(root, 'backup');
await backup(connection, process.env.DATA_DIR!, destination);
assert.ok(existsSync(path.join(destination, 'manifest.json')));
await withContentLock(async () => {
await assert.rejects(() => withContentLock(() => undefined), /in progress/);
});
const restoredDir = path.join(root, 'restored');
restore(destination, restoredDir);
const restored = openDatabase(restoredDir);
try { assert.equal(readContent(restored).settings.avatar.src, upload.url); assert.ok(existsSync(path.join(restoredDir, 'uploads', upload.key))); }
finally { restored.sqlite.close(); }
assert.throws(() => restore(destination, restoredDir), /new, empty/);
});