'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { GuideStore } = require('../../core/store'); const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers'); test('create a guide, add image steps, and reload everything from disk', (t) => { const root = makeTmpDir('store'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'Reset a password', descriptionHtml: '

How to reset a user password.

', placeholders: { Department: 'IT' }, }); const s1 = store.addStep(guide.guideId, { title: 'Open the admin portal' }, TINY_PNG, { width: 1, height: 1 }); const s2 = store.addStep(guide.guideId, { title: 'Click Users', descriptionHtml: '

In the left nav.

', annotations: [{ type: 'arrow', x: 0.1, y: 0.2, w: 0.3, h: 0.1 }], }, TINY_PNG, { width: 1, height: 1 }); const sub = store.addStep(guide.guideId, { kind: 'empty', title: 'Note', parentStepId: s2.stepId }); // A brand-new store instance must see exactly what was written. const fresh = new GuideStore(root); const loaded = fresh.getGuide(guide.guideId); assert.equal(loaded.title, 'Reset a password'); assert.equal(loaded.placeholders.Department, 'IT'); assert.deepEqual(loaded.stepsOrder, [s1.stepId, s2.stepId, sub.stepId]); const steps = fresh.listSteps(guide.guideId); assert.equal(steps.size, 3); assert.equal(steps.get(s2.stepId).annotations.length, 1); assert.equal(steps.get(s2.stepId).annotations[0].type, 'arrow'); assert.equal(steps.get(sub.stepId).parentStepId, s2.stepId); assert.equal(steps.get(sub.stepId).kind, 'empty'); // Image files exist and original equals what was captured. const imgPath = fresh.stepImagePath(guide.guideId, s1.stepId, 'original'); assert.deepEqual(fs.readFileSync(imgPath), TINY_PNG); }); test('step reorder, delete with substep reparenting, and order integrity', (t) => { const root = makeTmpDir('reorder'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'Order test' }); const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' }); const b = store.addStep(guide.guideId, { kind: 'empty', title: 'B' }); const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C', parentStepId: b.stepId }); store.reorderSteps(guide.guideId, [b.stepId, c.stepId, a.stepId]); assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [b.stepId, c.stepId, a.stepId]); // Reorder must reject losing or inventing steps. assert.throws(() => store.reorderSteps(guide.guideId, [a.stepId])); assert.throws(() => store.reorderSteps(guide.guideId, [a.stepId, b.stepId, 'step-bogus'])); // Deleting B reparents its substep C to top level and drops B from order. store.deleteStep(guide.guideId, b.stepId); const after = store.getGuide(guide.guideId); assert.deepEqual(after.stepsOrder, [c.stepId, a.stepId]); assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null); }); test('duplicate guide produces independent deep copy with fresh ids', (t) => { const root = makeTmpDir('dup'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'Original' }); const s1 = store.addStep(guide.guideId, { title: 'Step one' }, TINY_PNG, { width: 1, height: 1 }); const s2 = store.addStep(guide.guideId, { kind: 'empty', title: 'Child', parentStepId: s1.stepId }); const copy = store.duplicateGuide(guide.guideId); assert.notEqual(copy.guideId, guide.guideId); assert.equal(copy.title, 'Original (copy)'); assert.equal(copy.stepsOrder.length, 2); assert.ok(!copy.stepsOrder.includes(s1.stepId), 'copied steps must have new ids'); // Parent/child relationship is remapped to the new ids. const copySteps = store.listSteps(copy.guideId); const copiedChild = [...copySteps.values()].find((s) => s.title === 'Child'); const copiedParent = [...copySteps.values()].find((s) => s.title === 'Step one'); assert.equal(copiedChild.parentStepId, copiedParent.stepId); // Image bytes were copied, and editing the copy does not touch the original. assert.deepEqual(fs.readFileSync(store.stepImagePath(copy.guideId, copiedParent.stepId, 'original')), TINY_PNG); copiedParent.title = 'Edited in copy'; store.saveStep(copy.guideId, copiedParent); assert.equal(store.getStep(guide.guideId, s1.stepId).title, 'Step one'); }); test('delete moves guide to trash and restore brings it back intact', (t) => { const root = makeTmpDir('trash'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'Disposable' }); store.addStep(guide.guideId, { kind: 'empty', title: 'only step' }); store.deleteGuide(guide.guideId); assert.equal(store.guideExists(guide.guideId), false); const trash = store.listTrash(); assert.equal(trash.length, 1); const restoredId = store.restoreFromTrash(trash[0]); assert.equal(restoredId, guide.guideId); assert.equal(store.getGuide(guide.guideId).title, 'Disposable'); assert.equal(store.listSteps(guide.guideId).size, 1); store.deleteGuide(guide.guideId); store.purgeTrash(); assert.equal(store.listTrash().length, 0); }); test('folders and favorites round-trip', (t) => { const root = makeTmpDir('folders'); t.after(() => rmrf(root)); const store = new GuideStore(root); const g1 = store.createGuide({ title: 'In folder' }); const g2 = store.createGuide({ title: 'Loose' }); const folder = store.createFolder('IT Support'); store.moveGuideToFolder(g1.guideId, folder.id); store.setFavorite(g2.guideId, true); const fresh = new GuideStore(root); assert.equal(fresh.loadFolders().guideFolders[g1.guideId], folder.id); assert.equal(fresh.getGuide(g2.guideId).favorite, true); // Deleting the folder unassigns guides but keeps them in the library. fresh.deleteFolder(folder.id); assert.equal(fresh.loadFolders().guideFolders[g1.guideId], undefined); assert.equal(fresh.guideExists(g1.guideId), true); assert.throws(() => fresh.moveGuideToFolder(g1.guideId, 'folder-missing')); }); test('working image can be replaced (crop) and reset without touching original', (t) => { const root = makeTmpDir('crop'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'Crop test' }); const step = store.addStep(guide.guideId, { title: 'shot' }, TINY_PNG, { width: 1, height: 1 }); const cropped = Buffer.from('not-really-a-png-but-different-bytes'); store.setWorkingImage(guide.guideId, step.stepId, cropped, { width: 10, height: 5 }); assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'working')), cropped); assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'original')), TINY_PNG); assert.deepEqual(store.getStep(guide.guideId, step.stepId).image.size, { width: 10, height: 5 }); store.resetWorkingImage(guide.guideId, step.stepId, { width: 1, height: 1 }); assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'working')), TINY_PNG); }); test('stored HTML is sanitized: scripts and handlers cannot round-trip', (t) => { const root = makeTmpDir('sanitize'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'XSS attempt', descriptionHtml: '

hi x ok

', }); const loaded = store.getGuide(guide.guideId); // The dangerous parts are gone; the safe parts survive exactly. assert.equal( loaded.descriptionHtml, '

hi x ok

' ); const step = store.addStep(guide.guideId, { kind: 'content', descriptionHtml: '
bold
', }); assert.equal(store.getStep(guide.guideId, step.stepId).descriptionHtml, '
bold
'); }); test('guide ids are validated against path traversal', (t) => { const root = makeTmpDir('traverse'); t.after(() => rmrf(root)); const store = new GuideStore(root); assert.throws(() => store.getGuide('../../etc')); assert.throws(() => store.guideDir('a/b')); assert.throws(() => store.stepDir('ok', '..')); });