diff --git a/.changeset/deploy-minimal-bootstrap.md b/.changeset/deploy-minimal-bootstrap.md new file mode 100644 index 00000000..67ed9d5d --- /dev/null +++ b/.changeset/deploy-minimal-bootstrap.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +deploy: minimal server bootstrap with optional settings overrides + +Add deploy/bootstrap.sh, document what operators must keep locally vs what +ships in the image, and stop seeding a full settings.yaml on first boot so +release defaults merge with optional overrides only. diff --git a/deploy/README.md b/deploy/README.md index 1054040b..4b6ca848 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,112 +1,110 @@ # Production deployment Minimal Docker Compose stack for running nostream in production. The relay -container uses a pre-built image from GHCR instead of building on the server. +uses a pre-built image from GHCR; migrations and default settings ship inside +that image. This guide assumes a Linux host with Docker Engine and the Compose plugin installed. Container images are published automatically after CI succeeds on pushes to `main`. See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow. -Migrations ship inside that image (`migrations/` and `knexfile.js`). The -`nostream-migrate` service is a one-shot container of the same image that -runs `knex migrate:latest` before the relay starts. +## What the server keeps locally -## Prerequisites +| Path | Required | Changes with releases? | +|------|----------|------------------------| +| `.env` | **Yes** | No — your secrets and tuning | +| `.nostr/data/` | Created at runtime | No — Postgres data | +| `docker-compose.yml` | Yes (via bootstrap) | **Yes** — re-run bootstrap or PR 2 auto-sync | +| `postgresql.conf` | Yes (via bootstrap) | Rarely | +| `.nostr/settings.yaml` | **Optional** | Your overrides only | -Before deploying the compose stack: +**Do not copy** onto the host: `migrations/`, `knexfile.js`, or a full +`settings.yaml` from older docs. Migrations run from the image; settings +defaults come from the image and merge with any optional overrides file. -1. Install [Docker Engine](https://docs.docker.com/engine/install/) and the - Compose plugin on the host. -2. Create a deploy directory (for example `/opt/nostream`). -3. Copy `deploy/docker-compose.prod.yml` to `docker-compose.yml` in that directory. -4. Copy `deploy/settings.yaml.example` to `.nostr/settings.yaml` and edit for - your relay. -5. Create `.env` from `deploy/env.example` with production secrets. -6. Copy `postgresql.conf` from the repository root into the deploy directory. -7. Load `ghcr.io/cameri/nostream:main` on the host (see - [Image delivery on restricted networks](#image-delivery-on-restricted-networks) - if `docker pull` fails). +## Quick start -Do not copy `migrations/` or `knexfile.js` onto the host. Compose does not -mount them; changing files on disk will not change what the migrate service -runs. +From a git checkout on the server (or after copying the `deploy/` folder): -## Server layout +```bash +chmod +x deploy/bootstrap.sh +./deploy/bootstrap.sh /opt/nostream +``` + +Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:main`, then: + +```bash +cd /opt/nostream +docker compose up -d +``` + +Bootstrap copies release-managed files (`docker-compose.yml`, `postgresql.conf`) +from this repository. You only maintain `.env` and optional settings overrides. + +## Prerequisites + +1. [Docker Engine](https://docs.docker.com/engine/install/) and the Compose plugin +2. `ghcr.io/cameri/nostream:main` loaded on the host (see + [Image delivery](#image-delivery-on-restricted-networks) if `docker pull` fails) + +## Server layout after bootstrap ``` /opt/nostream/ -├── docker-compose.yml # copy from deploy/docker-compose.prod.yml +├── docker-compose.yml # from deploy/docker-compose.prod.yml +├── postgresql.conf # from repository root ├── .env # secrets (never commit) -├── .nostr/ -│ ├── settings.yaml # copy from deploy/settings.yaml.example -│ └── data/ # Postgres data (created on first start) -└── postgresql.conf # from repository root +└── .nostr/ + ├── settings.yaml # optional overrides only + └── data/ # Postgres data (created on first start) ``` ## Services -| Service | Image | Notes | -|-------------------|--------------------------------|------------------------------------------------------------| -| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded | -| nostream-db | postgres:15 | | -| nostream-cache | redis:7.0.5-alpine3.16 | | -| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay | - -The relay listens on `127.0.0.1:8008` by default. Expose it publicly with a -reverse proxy or tunnel (for example Cloudflare Tunnel) in front of that address. +| Service | Image | Notes | +|-------------------|--------------------------------|---------------------------------------------------------| +| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded | +| nostream-db | postgres:15 | | +| nostream-cache | redis:7.0.5-alpine3.16 | | +| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay | -The relay service waits for `nostream-migrate` to exit 0 -(`service_completed_successfully`) before it starts. +The relay listens on `127.0.0.1:8008`. Expose it with a reverse proxy or +tunnel (for example Cloudflare Tunnel). -## Deploy +The relay waits for `nostream-migrate` to exit 0 before it starts. -```bash -cd /opt/nostream +## Settings -mkdir -p .nostr/data .nostr/db-logs -chmod 755 .nostr -chown 1000:1000 .nostr/settings.yaml -chmod 600 .env .nostr/settings.yaml +Without `.nostr/settings.yaml`, the relay uses `resources/default-settings.yaml` +from the container image. When a release adds new settings keys, they appear +automatically from the image defaults. -docker pull postgres:15 -docker pull redis:7.0.5-alpine3.16 +To override specific values: +```bash +cp deploy/settings.yaml.example /opt/nostream/.nostr/settings.yaml +# edit overrides only — not a full copy of default-settings.yaml +chown 1000:1000 /opt/nostream/.nostr/settings.yaml +chmod 600 /opt/nostream/.nostr/settings.yaml docker compose up -d -docker compose logs -f nostream-migrate -docker compose logs -f nostream ``` +Or use the admin API/UI once `admin.enabled` is configured. + ## Verify ```bash docker compose ps -curl -s http://127.0.0.1:8008/ curl -s -H 'Accept: application/nostr+json' http://127.0.0.1:8008/ ``` -The second command should return NIP-11 relay metadata JSON. - ## Image delivery on restricted networks -Some hosts cannot reach GHCR over IPv4. Workarounds: +Some hosts cannot reach GHCR over IPv4: - **nostream image:** build or pull elsewhere, then `docker save` → transfer → - `docker load` on the server. Keep `pull_policy: never` on the nostream and - nostream-migrate services. One image is enough; migrations are already in it. -- **postgres / redis:** usually available from Docker Hub; if not, use the same - save/load approach. - -## Settings file permissions - -The nostream container runs as the `node` user (uid 1000). Ensure -`.nostr/settings.yaml` is owned by uid 1000 and readable by that user: - -```bash -chown 1000:1000 .nostr/settings.yaml -chmod 600 .nostr/settings.yaml -``` - -Without this, the relay falls back to default settings from the image. + `docker load`. Keep `pull_policy: never` on nostream and nostream-migrate. +- **postgres / redis:** usually on Docker Hub; use save/load if needed. ## Updating @@ -117,13 +115,19 @@ docker pull ghcr.io/cameri/nostream:main # or: docker load -i nostream-main.ta docker compose up -d ``` -`pull_policy: never` means Compose will not fetch a new digest by itself. -Load or pull the image first, then `up`. Compose recreates containers whose -image id changed, so `nostream-migrate` runs `migrate:latest` against the -schema baked into that image (no-op when already applied). - -If migrate does not re-run after a load, recreate it explicitly: +If migrate does not re-run after a load: ```bash docker compose up -d --force-recreate nostream-migrate nostream ``` + +When compose or `postgresql.conf` change in a release, re-run bootstrap against +the new checkout (or copy the updated files). Automated sync is planned separately. + +## Refresh release-managed files + +```bash +./deploy/bootstrap.sh /opt/nostream +``` + +Existing `.env` and `.nostr/settings.yaml` are preserved. diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100755 index 00000000..80655f17 --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bootstrap a production nostream host from this repository's deploy/ directory. +# +# Usage: +# ./deploy/bootstrap.sh [/opt/nostream] +# +# Creates the server layout, copies release-managed files from deploy/, and +# prepares .env for secrets. settings.yaml is optional — the relay uses image +# defaults until you add overrides (admin UI/API or .nostr/settings.yaml). + +TARGET="${1:-/opt/nostream}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +require_file() { + if [[ ! -f "$1" ]]; then + echo "error: required file not found: $1" >&2 + exit 1 + fi +} + +require_file "$SCRIPT_DIR/docker-compose.prod.yml" +require_file "$SCRIPT_DIR/env.example" +require_file "$REPO_ROOT/postgresql.conf" + +mkdir -p "$TARGET/.nostr/data" "$TARGET/.nostr/db-logs" + +install -m 644 "$SCRIPT_DIR/docker-compose.prod.yml" "$TARGET/docker-compose.yml" +install -m 644 "$REPO_ROOT/postgresql.conf" "$TARGET/postgresql.conf" + +if [[ ! -f "$TARGET/.env" ]]; then + install -m 600 "$SCRIPT_DIR/env.example" "$TARGET/.env" + echo "Created $TARGET/.env — edit secrets before starting the stack." +else + echo "Keeping existing $TARGET/.env" +fi + +if [[ ! -f "$TARGET/.nostr/settings.yaml" ]]; then + echo "No settings.yaml created — relay will use defaults from the container image." + echo "Add overrides later via the admin API or copy deploy/settings.yaml.example." +else + echo "Keeping existing $TARGET/.nostr/settings.yaml" +fi + +chmod 755 "$TARGET/.nostr" + +cat < process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr') - -export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml') - -export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'resources', 'default-settings.yaml') - -export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') - -export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') - export const toCategoryLabel = (key: string): string => { return key .split(/[_\-.]/) @@ -282,16 +287,10 @@ const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => { export const ensureSettingsExists = (): void => { const configDir = getConfigBaseDir() - const settingsPath = getSettingsFilePath() - const defaultsPath = getDefaultSettingsFilePath() if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } - - if (!fs.existsSync(settingsPath)) { - fs.copyFileSync(defaultsPath, settingsPath) - } } export const loadDefaults = (): Settings => { @@ -301,7 +300,13 @@ export const loadDefaults = (): Settings => { export const loadUserSettings = (): Settings => { ensureSettingsExists() - const raw = fs.readFileSync(getSettingsFilePath(), 'utf-8') + const settingsPath = getSettingsFilePath() + + if (!fs.existsSync(settingsPath)) { + return {} as Settings + } + + const raw = fs.readFileSync(settingsPath, 'utf-8') return (yaml.load(raw) as Settings) ?? ({} as Settings) } diff --git a/src/utils/settings-paths.ts b/src/utils/settings-paths.ts new file mode 100644 index 00000000..9bfa18c0 --- /dev/null +++ b/src/utils/settings-paths.ts @@ -0,0 +1,11 @@ +import { join } from 'path' + +export const getConfigBaseDir = (): string => process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr') + +export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml') + +export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'resources', 'default-settings.yaml') + +export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') + +export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') diff --git a/src/utils/settings.ts b/src/utils/settings.ts index ee639d63..89e87618 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -2,10 +2,11 @@ import fs from 'fs' import yaml from 'js-yaml' import { extname, join } from 'path' -import { mergeDeepRight } from 'ramda' import { createLogger } from '../factories/logger-factory' import { Settings } from '../@types/settings' +import { loadDefaults, loadMergedSettings } from './settings-config' +import { getConfigBaseDir, getDefaultSettingsFilePath, getSettingsFilePath } from './settings-paths' const logger = createLogger('settings') @@ -18,11 +19,11 @@ export class SettingsStatic { static _settings: Settings | undefined public static getSettingsFileBasePath(): string { - return process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr') + return getConfigBaseDir() } public static getDefaultSettingsFilePath(): string { - return join(process.cwd(), 'resources', 'default-settings.yaml') + return getDefaultSettingsFilePath() } public static loadAndParseYamlFile(path: string): Settings { @@ -71,23 +72,15 @@ export class SettingsStatic { } logger('creating settings') - const basePath = SettingsStatic.getSettingsFileBasePath() + const basePath = getConfigBaseDir() if (!fs.existsSync(basePath)) { - fs.mkdirSync(basePath) + fs.mkdirSync(basePath, { recursive: true }) } - const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath() - const fileType = SettingsStatic.settingsFileType(basePath) - const settingsFilePath = join(basePath, `settings.${fileType}`) - const defaults = SettingsStatic.loadSettings(defaultsFilePath, SettingsFileTypes.yaml) + const settingsFilePath = getSettingsFilePath() try { - if (fileType) { - SettingsStatic._settings = mergeDeepRight(defaults, SettingsStatic.loadSettings(settingsFilePath, fileType)) - } else { - SettingsStatic.saveSettings(basePath, defaults) - SettingsStatic._settings = mergeDeepRight({}, defaults) - } + SettingsStatic._settings = loadMergedSettings() if (typeof SettingsStatic._settings === 'undefined') { throw new Error('Unable to set settings') @@ -97,7 +90,8 @@ export class SettingsStatic { } catch (error) { logger('error reading config file at %s: %o', settingsFilePath, error) - return defaults + SettingsStatic._settings = loadDefaults() + return SettingsStatic._settings } } @@ -107,8 +101,8 @@ export class SettingsStatic { } public static watchSettings() { - const basePath = SettingsStatic.getSettingsFileBasePath() - const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath() + const basePath = getConfigBaseDir() + const defaultsFilePath = getDefaultSettingsFilePath() const fileType = SettingsStatic.settingsFileType(basePath) const reload = () => { diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 8acaf643..26e9aeec 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -1,6 +1,11 @@ import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' import { + ensureSettingsExists, + loadUserSettings, toCategoryLabel, getByPath, getTopLevelSettingCategories, @@ -118,4 +123,23 @@ describe('settings-guided-schema', () => { expect(requireSafeNonNegativeIntegerSettingValue('2048')).to.equal(undefined) expect(requireNonEmptySettingValue(' ')).to.equal('Value is required') }) + + it('does not create settings.yaml when ensuring config dir exists', () => { + const originalConfigDir = process.env.NOSTR_CONFIG_DIR + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-settings-config-')) + + try { + process.env.NOSTR_CONFIG_DIR = tmpDir + ensureSettingsExists() + expect(loadUserSettings()).to.deep.equal({}) + expect(fs.existsSync(path.join(tmpDir, 'settings.yaml'))).to.equal(false) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + if (originalConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = originalConfigDir + } + } + }) }) diff --git a/test/unit/utils/settings-paths.spec.ts b/test/unit/utils/settings-paths.spec.ts new file mode 100644 index 00000000..cc348267 --- /dev/null +++ b/test/unit/utils/settings-paths.spec.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai' + +import { + getConfigBaseDir, + getDefaultSettingsFilePath, + getSettingsAuditLogPath, + getSettingsBackupDir, + getSettingsFilePath, +} from '../../../src/utils/settings-paths' + +describe('settings paths', () => { + const originalConfigDir = process.env.NOSTR_CONFIG_DIR + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = originalConfigDir + } + }) + + it('defaults config dir to .nostr under cwd', () => { + delete process.env.NOSTR_CONFIG_DIR + + expect(getConfigBaseDir()).to.equal(`${process.cwd()}/.nostr`) + expect(getSettingsFilePath()).to.equal(`${process.cwd()}/.nostr/settings.yaml`) + expect(getSettingsBackupDir()).to.equal(`${process.cwd()}/.nostr/backups`) + expect(getSettingsAuditLogPath()).to.equal(`${process.cwd()}/.nostr/settings-audit.jsonl`) + }) + + it('honors NOSTR_CONFIG_DIR', () => { + process.env.NOSTR_CONFIG_DIR = '/srv/nostream/.nostr' + + expect(getConfigBaseDir()).to.equal('/srv/nostream/.nostr') + expect(getSettingsFilePath()).to.equal('/srv/nostream/.nostr/settings.yaml') + }) + + it('points default settings at bundled resources file', () => { + expect(getDefaultSettingsFilePath()).to.equal(`${process.cwd()}/resources/default-settings.yaml`) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index f0856024..dd461f6c 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -6,6 +6,7 @@ import { mergeDeepRight } from 'ramda' import { Settings } from '../../../src/@types/settings' import { SettingsFileTypes, SettingsStatic } from '../../../src/utils/settings' +import * as settingsConfig from '../../../src/utils/settings-config' describe('SettingsStatic', () => { describe('.getSettingsFilePath', () => { @@ -147,12 +148,8 @@ describe('SettingsStatic', () => { describe('.createSettings', () => { let existsSyncStub: Sinon.SinonStub let mkdirSyncStub: Sinon.SinonStub - let readdirSyncStub: Sinon.SinonStub - let getSettingsFileBasePathStub: Sinon.SinonStub - let getDefaultSettingsFilePathStub: Sinon.SinonStub - let settingsFileTypeStub: Sinon.SinonStub - let saveSettingsStub: Sinon.SinonStub - let loadSettingsStub: Sinon.SinonStub + let loadMergedSettingsStub: Sinon.SinonStub + let loadDefaultsStub: Sinon.SinonStub let sandbox: Sinon.SinonSandbox @@ -163,67 +160,34 @@ describe('SettingsStatic', () => { existsSyncStub = sandbox.stub(fs, 'existsSync') mkdirSyncStub = sandbox.stub(fs, 'mkdirSync') - readdirSyncStub = sandbox.stub(fs, 'readdirSync') - getSettingsFileBasePathStub = sandbox.stub(SettingsStatic, 'getSettingsFileBasePath') - getDefaultSettingsFilePathStub = sandbox.stub(SettingsStatic, 'getDefaultSettingsFilePath') - settingsFileTypeStub = sandbox.stub(SettingsStatic, 'settingsFileType') - saveSettingsStub = sandbox.stub(SettingsStatic, 'saveSettings') - loadSettingsStub = sandbox.stub(SettingsStatic, 'loadSettings') + loadMergedSettingsStub = sandbox.stub(settingsConfig, 'loadMergedSettings') + loadDefaultsStub = sandbox.stub(settingsConfig, 'loadDefaults') }) afterEach(() => { sandbox.restore() }) - it('creates settings from defaults if settings file is missing', () => { - getSettingsFileBasePathStub.returns('/some/path/settings.yaml') + it('loads merged settings from defaults and optional overrides', () => { existsSyncStub.returns(false) mkdirSyncStub.returns(true) - readdirSyncStub.returns(['file.yaml']) - loadSettingsStub.returns({}) + loadMergedSettingsStub.returns({ info: { name: 'relay' } }) - expect(SettingsStatic.createSettings()).to.be.an('object') - - expect(existsSyncStub).to.have.been.calledOnceWithExactly('/some/path/settings.yaml') - expect(getSettingsFileBasePathStub).to.have.been.calledOnce - expect(saveSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.yaml', Sinon.match.object) - expect(loadSettingsStub).to.have.been.called + expect(SettingsStatic.createSettings()).to.deep.equal({ info: { name: 'relay' } }) + expect(loadMergedSettingsStub).to.have.been.calledOnce + expect(loadDefaultsStub).not.to.have.been.called }) - it('returns default settings if saving settings file throws', () => { - const error = new Error('mistakes were made') - getSettingsFileBasePathStub.returns('/some/path/settings.json') - saveSettingsStub.throws(error) + it('returns image defaults if loading merged settings throws', () => { existsSyncStub.returns(false) - readdirSyncStub.returns(['file.yaml']) - loadSettingsStub.returns({}) - - expect(SettingsStatic.createSettings()).to.be.an('object') - - const settingsPathExistsChecks = existsSyncStub.getCalls().filter((call) => { - return call.args.length === 1 && call.args[0] === '/some/path/settings.json' - }) - - expect(settingsPathExistsChecks).to.have.lengthOf(1) - expect(getSettingsFileBasePathStub).to.have.been.calledOnce - expect(saveSettingsStub).to.have.been.calledOnceWithExactly('/some/path/settings.json', Sinon.match.object) - expect(loadSettingsStub).to.have.been.called - }) - - it('loads settings from file if settings file exists', () => { - loadSettingsStub.returns({ test: 'value' }) - getSettingsFileBasePathStub.returns('/some/path/settings.yaml') - getDefaultSettingsFilePathStub.returns('/some/path/settings.yaml') - existsSyncStub.returns(true) - readdirSyncStub.returns(['settings.yaml']) - settingsFileTypeStub.returns('yaml') + mkdirSyncStub.returns(true) + loadMergedSettingsStub.throws(new Error('mistakes were made')) + loadDefaultsStub.returns({ info: { name: 'default-relay' } }) - expect(SettingsStatic.createSettings()).to.be.an('object') + expect(SettingsStatic.createSettings()).to.deep.equal({ info: { name: 'default-relay' } }) - expect(existsSyncStub).to.have.been.calledWithExactly('/some/path/settings.yaml') - expect(getSettingsFileBasePathStub).to.have.been.calledOnce - expect(saveSettingsStub).not.to.have.been.called - expect(loadSettingsStub).to.have.been.calledWithExactly('/some/path/settings.yaml', 'yaml') + expect(loadMergedSettingsStub).to.have.been.calledOnce + expect(loadDefaultsStub).to.have.been.calledOnce }) it('returns cached settings if set', () => { @@ -232,10 +196,8 @@ describe('SettingsStatic', () => { expect(SettingsStatic.createSettings()).to.equal(cachedSettings) - expect(getSettingsFileBasePathStub).not.to.have.been.calledOnce expect(existsSyncStub).not.to.have.been.called - expect(saveSettingsStub).not.to.have.been.called - expect(loadSettingsStub).not.to.have.been.called + expect(loadMergedSettingsStub).not.to.have.been.called }) })