Security and quality

Test the store and validator

Move data rules into plain modules and test valid notes, malformed data, missing files, and round-trip persistence with Node.js.

The easiest Electron code to test is the code that doesn’t depend on Electron. That’s why readNotes() and writeNotes() live in a plain module with no require('electron'). We can test them with Node.js alone, in milliseconds, with no window.

Move validateNotes() out of src/index.js into its own src/validate-notes.js and export it, for the same reason. Then create test/notes-store.test.js, using the test runner built into Node.js:

const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')

const { readNotes, writeNotes } = require('../src/notes-store')
const { validateNotes } = require('../src/validate-notes')

test('notes survive a file round trip', async () => {
  const folder = await fs.mkdtemp(path.join(os.tmpdir(), 'notes-'))
  const file = path.join(folder, 'notes.json')
  const notes = [{ id: '1', title: 'Trip', body: 'Pack light' }]

  try {
    await writeNotes(file, notes)
    assert.deepEqual(await readNotes(file, validateNotes), notes)
  } finally {
    await fs.rm(folder, { recursive: true, force: true })
  }
})

The test creates a temporary folder with mkdtemp, writes notes, reads them back, and compares with deepEqual. The finally block removes the folder even when the assertion fails, so a red test doesn’t leave junk behind.

Run it:

node --test

Node finds files matching *.test.js and reports something like:

✔ notes survive a file round trip (12.3ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0

Test the failures too

A round trip is the happy path. The store earns its keep when things go wrong. Add tests for a missing file (expect []), invalid JSON (expect a throw), more than 1,000 notes, an oversized title, and unexpected properties.

When a test expects an error, assert which error. “Something threw” is too weak, because a typo in the test setup also throws:

assert.throws(
  () => validateNotes([{ id: '1', title: 'x'.repeat(201), body: '' }]),
  /Invalid note title/
)

The regex matches the message we wrote in the validator. If someone later changes the validator to accept 201-character titles, this test fails and says why.

Add a write-failure test too. Point writeNotes() at a destination that can’t be written, like a path inside a read-only folder, and confirm the original notes.json is still readable afterwards. That proves the temporary-file-and-rename strategy did its job.

What stays manual

Not everything fits in a Node test. Keep a short checklist for window lifecycle, menu shortcuts, save dialog cancellation, and the error messages the renderer shows.

The IPC boundary itself needs an integration test that runs inside Electron: the renderer invokes a channel, main checks the sender and the payload, and a plain result comes back. That test is slower and needs a display, so run it separately from the fast Node tests, not in the command you run on every save.

Lesson completed