diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ad9c828 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + pull_request: + push: + branches: [master, develop] + +concurrency: + group: tests-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 18 is the floor declared in package.json engines + node: [18, 20, 22] + + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + with: + version: 10.7.1 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm test diff --git a/src/actions/auth.js b/src/actions/auth.js index df1ac27..31e7bc2 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -33,9 +33,14 @@ async function checkCorrectOrganisation(orgUuid, opts) { const authData = await api({ path: '/authenticate', }); - if (authData.organisationUuid !== organisationUuid) { + // /authenticate returns identity flat at the top level. It also has a + // `data` key, but that holds OAuth authorization metadata + // (type/scopes/appUuid/authorizationUuid), not identity, so it must not + // be treated as a response envelope. + const authBody = authData ?? {}; + if (authBody.organisationUuid !== organisationUuid) { log( - `This configuration is for organisation ${organisationUuid} but you are currently in organisation ${authData.organisationUuid}`, + `This configuration is for organisation ${organisationUuid} but you are currently in organisation ${authBody.organisationUuid}`, 'white' ); const response = await inquirer.prompt([ @@ -46,12 +51,22 @@ async function checkCorrectOrganisation(orgUuid, opts) { }, ]); if (response.confirm) { + // Continuing here would run the command against the + // organisation the user just declined, so abort rather than + // returning. + if (!authBody.userUuid) { + log( + 'Your session does not identify a user, so the CLI cannot switch organisations for you. Switch organisation in the Raisely admin, then run raisely init again.', + 'red' + ); + process.exit(-1); + } const loader = ora( 'Switching to correct organisation ...' ).start(); try { await api({ - path: `/users/${authData.userUuid}/move`, + path: `/users/${authBody.userUuid}/move`, method: 'PUT', json: { data: { @@ -62,7 +77,7 @@ async function checkCorrectOrganisation(orgUuid, opts) { loader.succeed(); } catch (e) { error(e, loader); - throw e; + process.exit(-1); } } } diff --git a/src/actions/components.js b/src/actions/components.js index 01e41bb..6132529 100644 --- a/src/actions/components.js +++ b/src/actions/components.js @@ -1,5 +1,6 @@ import api from './api.js'; import { loadBabelCore } from './babel.js'; +import { loadConfig } from '../config.js'; import path from 'path'; import fs from 'fs'; @@ -15,11 +16,45 @@ async function getComponent(uuid, opts = {}) { }); } +async function resolveOrganisationUuid() { + const config = await loadConfig({ allowEmpty: true }); + if (config.organisationUuid) { + return config.organisationUuid; + } + + try { + const authData = await api({ + path: '/authenticate', + }); + // /authenticate returns organisationUuid flat at the top level; its + // `data` key holds OAuth authorization metadata, not identity. + // `/users/me` is not an option here: CLI tokens are app + // authorizations with no user record, so it resolves `me` to the + // authorization uuid and 404s. + if (authData?.organisationUuid) { + return authData.organisationUuid; + } + } catch { + // fall through to actionable error below + } + + return null; +} + +const ORGANISATION_RESOLUTION_ERROR = [ + 'The CLI could not resolve your Raisely organisation.', + 'Try signing out and back in, then re-initialize this directory if needed:', + ' raisely logout', + ' raisely login', + ' raisely init', + 'If you already have a .raisely.json here, make sure it includes organisationUuid (re-run raisely init to refresh it).', +].join('\n'); + export async function createComponent({ name, apiUrl }, opts = {}) { - // fetch the organisation ID - const user = await api({ - path: '/users/me', - }); + const organisationUuid = await resolveOrganisationUuid(); + if (!organisationUuid) { + throw ORGANISATION_RESOLUTION_ERROR; + } return await api({ path: `/components?private=1`, @@ -27,7 +62,7 @@ export async function createComponent({ name, apiUrl }, opts = {}) { json: { data: { name, - organisationUuid: user.data.organisationUuid, + organisationUuid, }, }, }); diff --git a/src/helpers.js b/src/helpers.js index 6548f8e..ed388b7 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -87,8 +87,27 @@ export async function informLocalDev(config) { const authData = await api({ path: '/authenticate', }); - const organisation = authData.data.organisation; - if (!organisation.private || !organisation.private.localDevelopment) { + // /authenticate carries identity flat at the top level but no organisation + // record, so the localDevelopment flag has to be read from the org itself. + const organisationUuid = + config?.organisationUuid || authData?.organisationUuid; + + let organisation; + if (organisationUuid) { + try { + const orgResponse = await api({ + path: `/organisations/${organisationUuid}?private=1`, + }); + organisation = orgResponse?.data; + } catch (e) { + // No OAuth app scope grants reading an organisation record, so this + // is a 403 for any CLI login and only succeeds for admin tokens + // supplied via RAISELY_TOKEN. The flag is advisory, so skip the + // warning rather than blocking the command. + } + } + + if (!organisation?.private?.localDevelopment) { // this is fine, we can continue without warning return true; } diff --git a/src/start.js b/src/start.js index f2d9764..34f760a 100644 --- a/src/start.js +++ b/src/start.js @@ -20,6 +20,7 @@ import { } from './actions/layout.js'; import { uploadStyles } from './actions/campaigns.js'; +import { uploadPage } from './actions/pages.js'; import { updateComponentFile, updateComponentConfig, @@ -32,21 +33,80 @@ function startLoader(message, oraImpl = ora) { return oraImpl(message).start(); } +/** + * Upload a single page JSON, applying the same guards as `raisely deploy`: + * the page must have a uuid and belong to a configured campaign. + */ +async function uploadChangedPage( + filenameRaw, + relative, + { config, fsModule, uploadPageFn, oraFn, errorFn } +) { + const loader = startLoader(`Saving ${relative}`, oraFn); + + let pageData; + try { + pageData = JSON.parse(fsModule.readFileSync(filenameRaw, 'utf8')); + } catch (e) { + loader.fail(`${relative} is not valid JSON, skipping upload`); + return; + } + + if (!pageData.uuid) { + loader.fail(`${relative} has no uuid, skipping upload`); + return; + } + + const campaigns = config?.campaigns ?? []; + if ( + !pageData.campaignUuid || + !campaigns.includes(pageData.campaignUuid) + ) { + loader.fail( + `${relative} does not belong to a configured campaign, skipping upload` + ); + return; + } + + try { + await uploadPageFn(pageData); + loader.succeed(); + } catch (e) { + errorFn(e, loader); + } +} + export async function handleCampaignChange( filenameRaw, { campaignsDir, token, + config, + fsModule = fs, uploadStylesFn = uploadStyles, + uploadPageFn = uploadPage, validateCampaignSassFn = validateCampaignSass, oraFn = ora, + errorFn = error, } = {} ) { const relative = path.relative(campaignsDir, filenameRaw); const parts = relative.split(path.sep); - // Only handle stylesheet changes: /stylesheets/... - if (parts.length < 3 || parts[1] !== 'stylesheets') return; + if (parts.length < 3) return; const campaignPath = parts[0]; + + if (parts[1] === 'pages' && relative.endsWith('.json')) { + return await uploadChangedPage(filenameRaw, relative, { + config, + fsModule, + uploadPageFn, + oraFn, + errorFn, + }); + } + + // Anything else under a campaign other than stylesheets is not uploaded + if (parts[1] !== 'stylesheets') return; const loader = startLoader(`Saving ${relative}`, oraFn); const validation = await validateCampaignSassFn({ campaign: campaignPath, @@ -143,9 +203,12 @@ export function registerStartWatchers( await handleCampaignChange(filenameRaw, { campaignsDir, token: config.token, + config, uploadStylesFn: dependencies.uploadStylesFn, + uploadPageFn: dependencies.uploadPageFn, validateCampaignSassFn: dependencies.validateCampaignSassFn, oraFn: dependencies.oraFn, + errorFn: dependencies.errorFn, }); } ); diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..cc22c7e --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const ora = vi.fn((label) => { + const loader = { + label, + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + warn: vi.fn(), + }; + loader.start.mockReturnValue(loader); + return loader; + }); + + return { + api: vi.fn(), + updateConfig: vi.fn(), + getCredentials: vi.fn(), + inquirerPrompt: vi.fn(), + log: vi.fn(), + error: vi.fn(), + ora, + }; +}); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('../src/config.js', () => ({ + updateConfig: mocks.updateConfig, +})); + +vi.mock('../src/credentials.js', () => ({ + getCredentials: mocks.getCredentials, +})); + +vi.mock('../src/helpers.js', () => ({ + log: mocks.log, + error: mocks.error, +})); + +vi.mock('inquirer', () => ({ + default: { + prompt: mocks.inquirerPrompt, + }, +})); + +vi.mock('ora', () => ({ + default: mocks.ora, +})); + +import { getToken } from '../src/actions/auth.js'; + +/** + * Shape observed from the live API: identity is flat at the top level, and the + * `data` key holds OAuth authorization metadata rather than an envelope. + */ +function authenticateResponse({ organisationUuid, userUuid }) { + return { + campaigns: [], + roles: [], + userUuid, + userEmail: 'fundraiser@example.org', + organisationUuid, + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, + }; +} + +describe('getToken', () => { + let exitSpy; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCredentials.mockResolvedValue({ token: 'token-123' }); + // Throwing keeps the rest of the function from running, the way a real + // process.exit would. + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit:${code}`); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + test('does not offer to switch organisation when the authenticated organisation already matches the config', async () => { + mocks.api.mockResolvedValue( + authenticateResponse({ + organisationUuid: 'org-1', + userUuid: 'user-1', + }) + ); + + await getToken({}, { organisationUuid: 'org-1' }); + + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.startsWith('/users/') + ) + ).toBe(false); + }); + + test('moves the signed-in user by uuid when the organisation genuinely differs', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + return authenticateResponse({ + organisationUuid: 'org-other', + userUuid: 'user-1', + }); + } + return {}; + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await getToken({}, { organisationUuid: 'org-1' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/users/user-1/move', + method: 'PUT', + json: { data: { organisationUuid: 'org-1' } }, + }) + ); + }); + + test('aborts without building a move path when the session identifies no user', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + // An app-token login: no userUuid at all. + return authenticateResponse({ + organisationUuid: 'org-other', + }); + } + return {}; + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await expect( + getToken({}, { organisationUuid: 'org-1' }) + ).rejects.toThrow('process.exit:-1'); + + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.startsWith('/users/') + ) + ).toBe(false); + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.includes('undefined') + ) + ).toBe(false); + }); + + test('aborts instead of continuing when the move request fails', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + return authenticateResponse({ + organisationUuid: 'org-other', + userUuid: 'user-1', + }); + } + throw new Error('move failed'); + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await expect( + getToken({}, { organisationUuid: 'org-1' }) + ).rejects.toThrow('process.exit:-1'); + + expect(mocks.error).toHaveBeenCalled(); + }); +}); diff --git a/tests/components.test.js b/tests/components.test.js new file mode 100644 index 0000000..33eda91 --- /dev/null +++ b/tests/components.test.js @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + loadConfig: vi.fn(), +})); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('../src/config.js', () => ({ + loadConfig: mocks.loadConfig, +})); + +import { createComponent } from '../src/actions/components.js'; + +const USERS_ME_404 = + 'https://api.raisely.com/v3/users/me (404) failed with message: user with uuid 5be64da0-xxxx... was not found'; + +describe('createComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockResolvedValue({}); + }); + + test('throws a clear error when organisation cannot be resolved', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/users/me') { + throw USERS_ME_404; + } + if (path === '/authenticate') { + return { data: {} }; + } + }); + + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /could not resolve your Raisely organisation/i + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely logout/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely login/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely init/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.not.toBe( + USERS_ME_404 + ); + expect( + mocks.api.mock.calls.some(([opts]) => opts.path === '/users/me') + ).toBe(false); + }); + + test('uses organisationUuid from config without calling /users/me', async () => { + mocks.loadConfig.mockResolvedValue({ + organisationUuid: 'org-from-config', + }); + + mocks.api.mockImplementation(async ({ path, method, json }) => { + if (path === '/users/me') { + throw new Error('/users/me should not be called'); + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-config', + }, + }, + }) + ); + expect( + mocks.api.mock.calls.some(([opts]) => opts.path === '/users/me') + ).toBe(false); + }); + + test('falls back to organisationUuid from /authenticate when config has none', async () => { + mocks.loadConfig.mockResolvedValue({}); + + mocks.api.mockImplementation(async ({ path, method }) => { + if (path === '/users/me') { + throw new Error('/users/me should not be called'); + } + if (path === '/authenticate') { + return { organisationUuid: 'org-from-auth' }; + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/authenticate' }) + ); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-auth', + }, + }, + }) + ); + }); + + test('ignores the OAuth metadata under data when reading /authenticate', async () => { + mocks.loadConfig.mockResolvedValue({}); + + mocks.api.mockImplementation(async ({ path, method }) => { + if (path === '/authenticate') { + return { + userUuid: 'user-1', + organisationUuid: 'org-from-auth', + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, + }; + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-auth', + }, + }, + }) + ); + }); + + test('throws a clear error when /authenticate fails', async () => { + const authenticateError = + 'https://api.raisely.com/v3/authenticate (401) failed with message: Unauthorized'; + + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + throw authenticateError; + } + }); + + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /could not resolve your Raisely organisation/i + ); + await expect(createComponent({ name: 'my-widget' })).rejects.not.toBe( + authenticateError + ); + }); +}); diff --git a/tests/helpers.test.js b/tests/helpers.test.js new file mode 100644 index 0000000..2a86404 --- /dev/null +++ b/tests/helpers.test.js @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + inquirerPrompt: vi.fn(), +})); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('inquirer', () => ({ + default: { + prompt: mocks.inquirerPrompt, + }, +})); + +import { informLocalDev } from '../src/helpers.js'; + +/** + * Shape observed from the live API: identity is flat at the top level, the + * `data` key holds OAuth authorization metadata, and there is no organisation + * record anywhere in the payload. + */ +const AUTHENTICATE_RESPONSE = { + campaigns: [], + roles: [], + userUuid: 'user-1', + userEmail: 'fundraiser@example.org', + organisationUuid: 'org-1', + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, +}; + +function mockApi({ organisation, organisationError }) { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') return AUTHENTICATE_RESPONSE; + if (path.startsWith('/organisations/')) { + if (organisationError) throw organisationError; + return { data: organisation }; + } + throw new Error(`Unexpected api call: ${path}`); + }); +} + +function captureConsole() { + const lines = []; + const spy = vi.spyOn(console, 'log').mockImplementation((message) => { + lines.push(String(message)); + }); + return { + lines, + restore() { + spy.mockRestore(); + }, + }; +} + +describe('informLocalDev', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('reads the localDevelopment flag from the organisation record, not from /authenticate', async () => { + mockApi({ organisation: { uuid: 'org-1', private: {} } }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/organisations/org-1?private=1' }) + ); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('falls back to the organisation uuid from /authenticate when config has none', async () => { + mockApi({ organisation: { uuid: 'org-1', private: {} } }); + + await expect(informLocalDev({})).resolves.toBe(true); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/organisations/org-1?private=1' }) + ); + }); + + test('continues without warning when local development is not required', async () => { + mockApi({ organisation: { uuid: 'org-1' } }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('prompts when local development is required and user confirms', async () => { + mockApi({ + organisation: { + uuid: 'org-1', + private: { localDevelopment: true }, + }, + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).toHaveBeenCalledOnce(); + }); + + test('aborts when local development is required and user declines', async () => { + mockApi({ + organisation: { + uuid: 'org-1', + private: { localDevelopment: true }, + }, + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: false }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(false); + expect(mocks.inquirerPrompt).toHaveBeenCalledOnce(); + }); + + test('continues when the organisation record cannot be fetched', async () => { + mockApi({ organisationError: 'organisation lookup failed' }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('does not crash when /authenticate carries no organisation record', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') return AUTHENTICATE_RESPONSE; + throw new Error('organisation unavailable'); + }); + + await expect(informLocalDev({})).resolves.toBe(true); + }); +}); diff --git a/tests/start.test.js b/tests/start.test.js index fdc3c71..5d75dff 100644 --- a/tests/start.test.js +++ b/tests/start.test.js @@ -29,6 +29,105 @@ function createOraHarness() { return { oraFn, loaders }; } +describe('start page uploads', () => { + function pageFs(contents) { + return { + readFileSync() { + return contents; + }, + }; + } + + test('saving a page uploads it', async () => { + const uploaded = []; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs( + '{"uuid":"page-1","campaignUuid":"campaign-1","title":"Home"}' + ), + uploadPageFn: async (pageData) => { + uploaded.push(pageData); + }, + oraFn, + } + ); + + assert.equal(uploaded.length, 1); + assert.equal(uploaded[0].uuid, 'page-1'); + assert.equal(loaders[0].succeeded, true); + }); + + test('skips a page belonging to a campaign that is not configured', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs( + '{"uuid":"page-1","campaignUuid":"other-campaign"}' + ), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); + + test('skips a page with no uuid', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs('{"campaignUuid":"campaign-1"}'), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); + + test('does not upload a half-written page that is invalid JSON', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs('{"uuid":"page-1",'), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); +}); + describe('start per-save validation', () => { test('bad SCSS save fails inline and skips upload', async () => { let uploadCalls = 0;