From b87b02b5cf6d5bfee821a458335b5ad1bf30d9c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:23:18 +0000 Subject: [PATCH 01/13] fix: persist all feature-slice state, not just core state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit partialize only whitelisted `state.state`, so every field a feature slice adds at the store's top level (profiles, drills, trainer notes, certification checklist, missions, reports, bulk, integrity) was silently dropped from localStorage on every reload — a trainee's progress vanished the moment they refreshed the page. Persist every non-function field instead of a hardcoded key list, so the next feature slice added to the store doesn't quietly repeat this bug. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/__tests__/persistence.test.ts | 25 +++++++++++++++++++ src/engine/ad-console/store.ts | 9 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/engine/ad-console/__tests__/persistence.test.ts b/src/engine/ad-console/__tests__/persistence.test.ts index 85a9e9a..a4c06cc 100644 --- a/src/engine/ad-console/__tests__/persistence.test.ts +++ b/src/engine/ad-console/__tests__/persistence.test.ts @@ -50,3 +50,28 @@ describe('export/import state', () => { expect(typeof useAdConsoleStore.getState().state.version).toBe('string'); }); }); + +describe('localStorage persistence (partialize)', () => { + it('includes feature-slice state, not just the core `state` field', async () => { + const { useAdConsoleStore } = await import('../store'); + useAdConsoleStore.getState().createProfile('Alex'); + + const partialize = useAdConsoleStore.persist.getOptions().partialize!; + const persisted = partialize(useAdConsoleStore.getState()) as Record; + + expect(persisted.state).toBeDefined(); + expect(persisted.profiles).toBeDefined(); + expect((persisted.profiles as { name: string }[]).some((p) => p.name === 'Alex')).toBe(true); + expect(persisted.activeProfileId).toBeDefined(); + }); + + it('excludes functions from the persisted snapshot', async () => { + const { useAdConsoleStore } = await import('../store'); + const partialize = useAdConsoleStore.persist.getOptions().partialize!; + const persisted = partialize(useAdConsoleStore.getState()) as Record; + + for (const value of Object.values(persisted)) { + expect(typeof value).not.toBe('function'); + } + }); +}); diff --git a/src/engine/ad-console/store.ts b/src/engine/ad-console/store.ts index f371017..6eb8699 100644 --- a/src/engine/ad-console/store.ts +++ b/src/engine/ad-console/store.ts @@ -132,9 +132,12 @@ export const useAdConsoleStore = create()( { name: PERSIST_KEY, storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ - state: state.state, - }), + // Persist every non-function field so newly added slices are + // included automatically — a hardcoded key list silently drops + // whatever the next feature slice adds (see: this bug). + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([, value]) => typeof value !== 'function'), + ) as Partial, merge: (persisted, current) => ({ ...current, ...(persisted as Partial), From 752a8d92bfcbd234460358db3ab42eff48caa11e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:23:28 +0000 Subject: [PATCH 02/13] fix: duplicateCampaign no longer collapses multi-ad-group targets Every target, negative, productAd, and ad was hardcoded to the first new ad group's id (newAgId) regardless of which ad group it originally belonged to. Duplicating a campaign with multiple ad groups silently merged everything into one, leaving the other ad groups empty. Build a map from old ad group id to new ad group id and use it to re-attach each item to its own ad group instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/core/__tests__/engine.test.ts | 21 +++++++++++++++++++ src/engine/ad-console/core/engine/campaign.ts | 18 ++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/engine/ad-console/core/__tests__/engine.test.ts b/src/engine/ad-console/core/__tests__/engine.test.ts index 692ab48..1ed96fe 100644 --- a/src/engine/ad-console/core/__tests__/engine.test.ts +++ b/src/engine/ad-console/core/__tests__/engine.test.ts @@ -140,6 +140,27 @@ describe('campaign status toggling', () => { expect(dup.metrics.impressions).toBe(0); expect(dup.name).toContain('copy'); }); + + it('preserves per-ad-group target distribution when duplicating a multi-ad-group campaign', () => { + const c = makeCampaign({ + adGroups: [ + { id: 'AG1', campaignId: 'C1', name: 'AG1', status: 'Enabled', defaultBid: 0.75, metrics: { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 } }, + { id: 'AG2', campaignId: 'C1', name: 'AG2', status: 'Enabled', defaultBid: 0.75, metrics: { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 } }, + ], + targets: [ + { id: 'T1', campaignId: 'C1', adGroupId: 'AG1', type: 'Keyword', value: 'a', match: 'Exact', bid: 1, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }, + { id: 'T2', campaignId: 'C1', adGroupId: 'AG2', type: 'Keyword', value: 'b', match: 'Exact', bid: 1, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }, + { id: 'T3', campaignId: 'C1', adGroupId: 'AG2', type: 'Keyword', value: 'c', match: 'Exact', bid: 1, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }, + ], + }); + const dup = duplicateCampaign(c); + expect(dup.adGroups).toHaveLength(2); + const [newAg1, newAg2] = dup.adGroups; + const targetsByAg = (agId: string) => dup.targets.filter((t) => t.adGroupId === agId); + expect(targetsByAg(newAg1.id)).toHaveLength(1); + expect(targetsByAg(newAg2.id)).toHaveLength(2); + expect(dup.targets.map((t) => t.value).sort()).toEqual(['a', 'b', 'c']); + }); }); describe('target operations', () => { diff --git a/src/engine/ad-console/core/engine/campaign.ts b/src/engine/ad-console/core/engine/campaign.ts index 20fe41d..0f99922 100644 --- a/src/engine/ad-console/core/engine/campaign.ts +++ b/src/engine/ad-console/core/engine/campaign.ts @@ -204,7 +204,13 @@ export function archiveCampaign(c: Campaign): Campaign { export function duplicateCampaign(c: Campaign): Campaign { const newId = generateId('C-' + c.type); - const newAgId = generateId('AG'); + // Map each original ad group id to a fresh one so targets/ads/negatives + // can be re-attached to the ad group they actually belonged to, instead + // of collapsing everything onto a single new ad group. + const agIdMap = new Map(c.adGroups.map((ag) => [ag.id, generateId('AG')])); + const fallbackAgId = agIdMap.values().next().value ?? generateId('AG'); + const mapAgId = (oldAgId: string) => agIdMap.get(oldAgId) ?? fallbackAgId; + return normalizeCampaign({ ...c, id: newId, @@ -214,14 +220,14 @@ export function duplicateCampaign(c: Campaign): Campaign { history: [], adGroups: c.adGroups.map((ag) => ({ ...ag, - id: ag.id === c.adGroups[0]?.id ? newAgId : generateId('AG'), + id: agIdMap.get(ag.id)!, campaignId: newId, })), targets: c.targets.map((t) => ({ ...t, id: generateId('T'), campaignId: newId, - adGroupId: newAgId, + adGroupId: mapAgId(t.adGroupId), impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0, })), searchTerms: [], @@ -229,7 +235,7 @@ export function duplicateCampaign(c: Campaign): Campaign { ...n, id: generateId('NEG'), campaignId: newId, - adGroupId: newAgId, + adGroupId: n.adGroupId ? mapAgId(n.adGroupId) : n.adGroupId, })), budgetRules: c.budgetRules.map((r) => ({ ...r, @@ -240,14 +246,14 @@ export function duplicateCampaign(c: Campaign): Campaign { ...pa, id: generateId('PA'), campaignId: newId, - adGroupId: newAgId, + adGroupId: mapAgId(pa.adGroupId), metrics: metricDefaults({}), })), ads: c.ads.map((a) => ({ ...a, id: generateId('AD'), campaignId: newId, - adGroupId: newAgId, + adGroupId: mapAgId(a.adGroupId), metrics: metricDefaults({}), })), }); From f2e0d3d594bf9f56c6466652ec0a1ce30f9dfd84 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:31:32 +0000 Subject: [PATCH 03/13] fix: deleteProfile no longer leaves a dangling activeProfileId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the only remaining profile reset activeProfileId to the literal 'p-default' without reinserting a default profile into the roster, leaving profiles: [] and activeProfileId pointing at nothing — any selector doing profiles.find(p => p.id === activeProfileId) would come back undefined. Reseed defaultProfile() when the roster would otherwise go empty. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- src/engine/ad-console/__tests__/feature-stores.test.ts | 8 ++++++++ src/engine/ad-console/features/profiles/store.ts | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/engine/ad-console/__tests__/feature-stores.test.ts b/src/engine/ad-console/__tests__/feature-stores.test.ts index 464b8f0..517b09c 100644 --- a/src/engine/ad-console/__tests__/feature-stores.test.ts +++ b/src/engine/ad-console/__tests__/feature-stores.test.ts @@ -52,6 +52,14 @@ describe('ProfilesSlice', () => { expect(getStore().profiles.some((p) => p.name === 'to-delete')).toBe(false); }); + it('deleteProfile reseeds a default profile instead of leaving an empty, dangling roster', () => { + const onlyProfileId = getStore().profiles[0].id; + getStore().deleteProfile(onlyProfileId); + expect(getStore().profiles).toHaveLength(1); + expect(getStore().activeProfileId).toBe(getStore().profiles[0].id); + expect(getStore().profiles.find((p) => p.id === getStore().activeProfileId)).toBeDefined(); + }); + it('switchProfile switches', () => { getStore().createProfile('switchable'); const id = getStore().profiles.find((p) => p.name === 'switchable')!.id; diff --git a/src/engine/ad-console/features/profiles/store.ts b/src/engine/ad-console/features/profiles/store.ts index 85bef27..ed9e8f7 100644 --- a/src/engine/ad-console/features/profiles/store.ts +++ b/src/engine/ad-console/features/profiles/store.ts @@ -41,10 +41,13 @@ export const createProfilesSlice: StateCreator = (set, get) => ({ deleteProfile: (id) => { set((s) => { - const filtered = deleteProfile(s.profiles, id); + let filtered = deleteProfile(s.profiles, id); + // Never leave the roster empty — reseed the default profile so + // activeProfileId always resolves to a real entry in `profiles`. + if (filtered.length === 0) filtered = [defaultProfile()]; return { profiles: filtered, - activeProfileId: s.activeProfileId === id ? (filtered[0]?.id || 'p-default') : s.activeProfileId, + activeProfileId: s.activeProfileId === id ? filtered[0].id : s.activeProfileId, }; }); }, From 1c6f5d980e28a11a66e727ddad9d3bea6cf98aa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:31:42 +0000 Subject: [PATCH 04/13] fix: scope campaign update/delete mutations by userId PUT and DELETE checked ownership via a separate findFirst({ id, userId }) query, but the actual update/delete calls used where: { id } alone. Not exploitable today since the check and mutation run in the same request, but any future refactor that separates them (a queued job, a transfer feature, reordered code) could silently reintroduce cross-user access with no compiler or test signal. Prisma's extended where-unique-input lets id and userId be combined directly on the mutation, so the mutation itself now proves ownership instead of relying entirely on the preceding check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../campaigns/[id]/__tests__/route.test.ts | 88 +++++++++++++++++++ src/app/api/campaigns/[id]/route.ts | 4 +- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/app/api/campaigns/[id]/__tests__/route.test.ts diff --git a/src/app/api/campaigns/[id]/__tests__/route.test.ts b/src/app/api/campaigns/[id]/__tests__/route.test.ts new file mode 100644 index 0000000..aaf124f --- /dev/null +++ b/src/app/api/campaigns/[id]/__tests__/route.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for /api/campaigns/[id] ownership scoping. + * + * PUT and DELETE verified ownership via a separate `findFirst({ id, userId })` + * check, but the mutating `update`/`delete` calls used `where: { id }` alone — + * a defense-in-depth gap where any future refactor that separates the check + * from the mutation could let one user modify another user's campaign by id. + * The fix scopes the mutation itself by `userId` too. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { prismaMock, authMock } = vi.hoisted(() => { + const prismaMock = { + campaign: { + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; + const authMock = vi.fn(); + return { prismaMock, authMock }; +}); + +vi.mock('@/lib/prisma', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/auth', () => ({ + auth: authMock, +})); + +import { PUT, DELETE } from '../route'; + +const USER = { user: { id: 'user-1' } }; +const params = Promise.resolve({ id: 'campaign-1' }); + +function putRequest(body: unknown): Request { + return new Request('http://localhost/api/campaigns/campaign-1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + authMock.mockResolvedValue(USER); + prismaMock.campaign.findFirst.mockResolvedValue({ id: 'campaign-1', userId: 'user-1' }); + prismaMock.campaign.update.mockResolvedValue({ id: 'campaign-1' }); + prismaMock.campaign.delete.mockResolvedValue({ id: 'campaign-1' }); +}); + +describe('PUT /api/campaigns/[id]', () => { + it('scopes the update to the authenticated user, not just the ownership check', async () => { + await PUT(putRequest({ name: 'Renamed' }), { params }); + expect(prismaMock.campaign.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'campaign-1', userId: 'user-1' } }), + ); + }); + + it('rejects unauthenticated requests with 401 and never touches the DB', async () => { + authMock.mockResolvedValueOnce(null); + const res = await PUT(putRequest({ name: 'x' }), { params }); + expect(res.status).toBe(401); + expect(prismaMock.campaign.update).not.toHaveBeenCalled(); + }); + + it('returns 404 when the campaign is not owned by the caller', async () => { + prismaMock.campaign.findFirst.mockResolvedValueOnce(null); + const res = await PUT(putRequest({ name: 'x' }), { params }); + expect(res.status).toBe(404); + expect(prismaMock.campaign.update).not.toHaveBeenCalled(); + }); +}); + +describe('DELETE /api/campaigns/[id]', () => { + it('scopes the delete to the authenticated user, not just the ownership check', async () => { + await DELETE(new Request('http://localhost/api/campaigns/campaign-1', { method: 'DELETE' }), { params }); + expect(prismaMock.campaign.delete).toHaveBeenCalledWith({ where: { id: 'campaign-1', userId: 'user-1' } }); + }); + + it('returns 404 when the campaign is not owned by the caller', async () => { + prismaMock.campaign.findFirst.mockResolvedValueOnce(null); + const res = await DELETE(new Request('http://localhost/api/campaigns/campaign-1', { method: 'DELETE' }), { params }); + expect(res.status).toBe(404); + expect(prismaMock.campaign.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts index 960805c..12299fe 100644 --- a/src/app/api/campaigns/[id]/route.ts +++ b/src/app/api/campaigns/[id]/route.ts @@ -86,7 +86,7 @@ export async function PUT( } const campaign = await prisma.campaign.update({ - where: { id }, + where: { id, userId: session.user.id }, data: updateData, }); @@ -116,7 +116,7 @@ export async function DELETE( } await prisma.campaign.delete({ - where: { id }, + where: { id, userId: session.user.id }, }); return NextResponse.json({ message: 'Deleted' }); From 79d91dfaf380a545db3f48f23f841d1f8b218220 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:31:50 +0000 Subject: [PATCH 05/13] fix: target.ts fail-fast and no-op edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setTargetBid (and adjustTargetBid, which calls it) never validated newBid, unlike sibling addTarget — a NaN or negative bid was silently clamped via Math.max instead of throwing ValidationError, contradicting the codebase's fail-fast convention. Added the same assertFiniteNonNegative guard addTarget already uses. - pauseTarget didn't check whether targetId matched an existing target before proceeding, unlike removeTarget/adjustTargetBid/ setTargetStatus — an unknown id pushed a blank string into history and returned a new object reference for what should have been a no-op. Now early-returns the campaign unchanged, matching its sibling functions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/core/__tests__/engine.test.ts | 17 +++++++++++++++++ src/engine/ad-console/core/engine/target.ts | 16 ++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/engine/ad-console/core/__tests__/engine.test.ts b/src/engine/ad-console/core/__tests__/engine.test.ts index 1ed96fe..3e9bb92 100644 --- a/src/engine/ad-console/core/__tests__/engine.test.ts +++ b/src/engine/ad-console/core/__tests__/engine.test.ts @@ -195,10 +195,27 @@ describe('target operations', () => { expect(adjustTargetBid(c, 'T1', 1.5).targets[0]!.bid).toBe(1.5); }); + it('fails fast on a NaN bid instead of silently storing NaN', () => { + const c = makeCampaign({ targets: [{ id: 'T1', campaignId: 'C1', adGroupId: 'AG1', type: 'Keyword', value: 'kw', match: 'Exact', bid: 0.75, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }] }); + expect(() => setTargetBid(c, 'T1', NaN)).toThrow(); + }); + + it('fails fast on a negative bid', () => { + const c = makeCampaign({ targets: [{ id: 'T1', campaignId: 'C1', adGroupId: 'AG1', type: 'Keyword', value: 'kw', match: 'Exact', bid: 0.75, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }] }); + expect(() => setTargetBid(c, 'T1', -5)).toThrow(); + }); + it('pauses then re-enables a target', () => { const c = makeCampaign({ targets: [{ id: 'T1', campaignId: 'C1', adGroupId: 'AG1', type: 'Keyword', value: 'kw', match: 'Exact', bid: 0.75, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }] }); expect(pauseTarget(c, 'T1').targets[0]!.status).toBe('Paused'); }); + + it('pauseTarget is a no-op for an unknown target id (no blank history entry)', () => { + const c = makeCampaign({ targets: [{ id: 'T1', campaignId: 'C1', adGroupId: 'AG1', type: 'Keyword', value: 'kw', match: 'Exact', bid: 0.75, status: 'Enabled', impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }] }); + const result = pauseTarget(c, 'does-not-exist'); + expect(result).toBe(c); + expect(result.history).not.toContain(''); + }); }); describe('negatives and harvesting', () => { diff --git a/src/engine/ad-console/core/engine/target.ts b/src/engine/ad-console/core/engine/target.ts index 8e6d448..e5b6d6d 100644 --- a/src/engine/ad-console/core/engine/target.ts +++ b/src/engine/ad-console/core/engine/target.ts @@ -132,6 +132,7 @@ export function removeTarget(c: Campaign, targetId: string): Campaign { } export function setTargetBid(c: Campaign, targetId: string, newBid: number): Campaign { + assertFiniteNonNegative('bid', newBid); return { ...c, targets: c.targets.map((t) => @@ -158,19 +159,18 @@ export function adjustTargetBid(c: Campaign, targetId: string, multiplier: numbe } export function pauseTarget(c: Campaign, targetId: string): Campaign { + const t = c.targets.find((x) => x.id === targetId); + if (!t) return c; return { ...c, - targets: c.targets.map((t) => - t.id === targetId - ? { ...t, status: (t.status === 'Paused' ? 'Enabled' : 'Paused') as CampaignStatus } - : t, + targets: c.targets.map((x) => + x.id === targetId + ? { ...x, status: (x.status === 'Paused' ? 'Enabled' : 'Paused') as CampaignStatus } + : x, ), history: [ ...c.history, - (() => { - const t = c.targets.find((x) => x.id === targetId); - return t ? `Target "${t.value}" (${t.type}) ${t.status === 'Paused' ? 'enabled' : 'paused'}` : ''; - })(), + `Target "${t.value}" (${t.type}) ${t.status === 'Paused' ? 'enabled' : 'paused'}`, ], }; } From 77f0f42ecd9819c86ac72d9081eec102f91e1596 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:33:43 +0000 Subject: [PATCH 06/13] fix: validate dailyBudget/defaultBid in updateCampaignSettings updateCampaignSettings never validated dailyBudget/defaultBid (unlike normalizeCampaign, which clamps them), so a negative budget or a NaN bid was written straight into the campaign and even rendered into history text like "$-50.00". The store slice compounded this by typing `updates` as Record, discarding the compile- time protection the function's own signature already provided. Added assertFiniteNonNegative checks matching the rest of the engine's fail-fast convention, and tightened the slice's type to the function's actual Partial> signature. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/core/__tests__/campaignGoal.test.ts | 10 ++++++++++ src/engine/ad-console/core/engine/campaign.ts | 5 ++++- src/engine/ad-console/core/slices/core.ts | 5 ++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/engine/ad-console/core/__tests__/campaignGoal.test.ts b/src/engine/ad-console/core/__tests__/campaignGoal.test.ts index 36b46d8..fa9bdc0 100644 --- a/src/engine/ad-console/core/__tests__/campaignGoal.test.ts +++ b/src/engine/ad-console/core/__tests__/campaignGoal.test.ts @@ -164,4 +164,14 @@ describe('updateCampaignSettings - creativeStatus', () => { expect(result.dailyBudget).toBe(c.dailyBudget); expect(result.defaultBid).toBe(c.defaultBid); }); + + it('fails fast on a negative dailyBudget instead of storing it', () => { + const c = makeCampaign(); + expect(() => updateCampaignSettings(c, { dailyBudget: -50 })).toThrow(); + }); + + it('fails fast on a NaN defaultBid instead of storing it', () => { + const c = makeCampaign(); + expect(() => updateCampaignSettings(c, { defaultBid: NaN })).toThrow(); + }); }); diff --git a/src/engine/ad-console/core/engine/campaign.ts b/src/engine/ad-console/core/engine/campaign.ts index 0f99922..5cd7bae 100644 --- a/src/engine/ad-console/core/engine/campaign.ts +++ b/src/engine/ad-console/core/engine/campaign.ts @@ -5,7 +5,7 @@ import type { Campaign, CampaignType, CampaignStatus, Target, AdGroup, ProductAd, Ad, } from '../types'; -import { assertCampaignType, assertCampaignStatus } from '../../../../lib/validation'; +import { assertCampaignType, assertCampaignStatus, assertFiniteNonNegative } from '../../../../lib/validation'; import { generateId } from './id'; import { metricDefaults } from './metrics'; @@ -263,6 +263,9 @@ export function updateCampaignSettings( c: Campaign, updates: Partial>, ): Campaign { + if (updates.dailyBudget !== undefined) assertFiniteNonNegative('dailyBudget', updates.dailyBudget); + if (updates.defaultBid !== undefined) assertFiniteNonNegative('defaultBid', updates.defaultBid); + const changes: string[] = []; if (updates.dailyBudget !== undefined && updates.dailyBudget !== c.dailyBudget) { changes.push(`budget $${c.dailyBudget.toFixed(2)} → $${updates.dailyBudget.toFixed(2)}`); diff --git a/src/engine/ad-console/core/slices/core.ts b/src/engine/ad-console/core/slices/core.ts index 5db06fa..93e388f 100644 --- a/src/engine/ad-console/core/slices/core.ts +++ b/src/engine/ad-console/core/slices/core.ts @@ -26,7 +26,10 @@ export interface CoreSlice { openMobileMenu: () => void; closeMobileMenu: () => void; resetAll: () => void; - updateCampaignSettings: (id: string, updates: Record) => void; + updateCampaignSettings: ( + id: string, + updates: Partial>, + ) => void; savePlacements: (id: string, placements: { top: number; product: number; rest: number }) => void; exportState: () => string; importState: (json: string) => boolean; From 9512d6f035cf63cfd9c3bf74767e918ad0046630 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:35:48 +0000 Subject: [PATCH 07/13] fix: guard addNote/renameProfile against blank input at the store boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both actions called an engine function that throws ValidationError on blank input (assertNonEmpty) from inside a Zustand set() updater, where the exception propagates uncaught — a blank note or profile rename would crash the calling event handler instead of failing gracefully. The engine functions are correct to throw (fail-fast is the intended contract there); the gap was the store not guarding the boundary before calling them, unlike the equivalent UI-level checks elsewhere in the app. Added the same non-empty guard at the store action level so blank input is a no-op regardless of caller. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/__tests__/feature-stores.test.ts | 13 +++++++++++++ src/engine/ad-console/features/profiles/store.ts | 4 ++++ src/engine/ad-console/features/trainer/store.ts | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/src/engine/ad-console/__tests__/feature-stores.test.ts b/src/engine/ad-console/__tests__/feature-stores.test.ts index 517b09c..b932112 100644 --- a/src/engine/ad-console/__tests__/feature-stores.test.ts +++ b/src/engine/ad-console/__tests__/feature-stores.test.ts @@ -73,6 +73,13 @@ describe('ProfilesSlice', () => { getStore().renameProfile(id, 'new-name'); expect(getStore().profiles.some((p) => p.name === 'new-name')).toBe(true); }); + + it('renameProfile no-ops on a blank name instead of throwing', () => { + getStore().createProfile('keep-me'); + const id = getStore().profiles.find((p) => p.name === 'keep-me')!.id; + expect(() => getStore().renameProfile(id, ' ')).not.toThrow(); + expect(getStore().profiles.find((p) => p.id === id)!.name).toBe('keep-me'); + }); }); describe('TrainerSlice', () => { @@ -84,6 +91,12 @@ describe('TrainerSlice', () => { expect(getStore().notes[0].text).toBe('Test note'); }); + it('addNote no-ops on blank/whitespace text instead of throwing', () => { + const before = getStore().notes.length; + expect(() => getStore().addNote(' ')).not.toThrow(); + expect(getStore().notes).toHaveLength(before); + }); + it('deleteNote deletes', () => { getStore().addNote('to-delete'); const id = getStore().notes[0].id; diff --git a/src/engine/ad-console/features/profiles/store.ts b/src/engine/ad-console/features/profiles/store.ts index ed9e8f7..6757d72 100644 --- a/src/engine/ad-console/features/profiles/store.ts +++ b/src/engine/ad-console/features/profiles/store.ts @@ -34,6 +34,10 @@ export const createProfilesSlice: StateCreator = (set, get) => ({ }, renameProfile: (id, name) => { + // renameProfile throws on blank input (fail-fast engine convention) — + // guard here so a blank name is a no-op instead of an uncaught error + // inside the set() updater. + if (!name.trim()) return; set((s) => ({ profiles: renameProfile(s.profiles, id, name), })); diff --git a/src/engine/ad-console/features/trainer/store.ts b/src/engine/ad-console/features/trainer/store.ts index c94f936..5044554 100644 --- a/src/engine/ad-console/features/trainer/store.ts +++ b/src/engine/ad-console/features/trainer/store.ts @@ -23,6 +23,10 @@ export const createTrainerSlice: StateCreator = (set, get) => ({ certificationChecklist: DEFAULT_CERTIFICATION.map((c) => ({ ...c })), addNote: (text) => { + // addNote throws on blank input (fail-fast engine convention) — guard + // here so a blank submission is a no-op instead of an uncaught error + // inside the set() updater. + if (!text.trim()) return; set((s) => ({ notes: [addNote(text), ...s.notes] })); }, From c05d9b2d7ac5e3a3ade6a7ebc743d2889e6e3234 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:38:12 +0000 Subject: [PATCH 08/13] fix: startMission falls back to an idle session on unknown mission id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike the analogous startDrill (which falls back to createSession() for an unknown id), startMission built a session for any id with no existence check. Since completeMissionStep looks the mission back up by id on every call, an unresolvable id left the session permanently stuck at step 0 with no error — indistinguishable from a UI freeze. Mirrors startDrill's pattern: fall back to an empty/idle session instead of a session that can never resolve. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/features/missions/__tests__/engine.test.ts | 6 ++++++ src/engine/ad-console/features/missions/engine.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/src/engine/ad-console/features/missions/__tests__/engine.test.ts b/src/engine/ad-console/features/missions/__tests__/engine.test.ts index 021ddb6..697588b 100644 --- a/src/engine/ad-console/features/missions/__tests__/engine.test.ts +++ b/src/engine/ad-console/features/missions/__tests__/engine.test.ts @@ -34,6 +34,12 @@ describe('mission session lifecycle', () => { expect(s.score).toBe(100); }); + it('falls back to an empty session for an unknown mission id instead of an unresolvable session', () => { + const s = startMission('typo-id'); + expect(s).toEqual(createMissionSession()); + expect(s.missionId).toBeNull(); + }); + it('lowers score by 10 per hint', () => { const s0 = startMission('sp-harvest-negate'); const s1 = useHint(s0); diff --git a/src/engine/ad-console/features/missions/engine.ts b/src/engine/ad-console/features/missions/engine.ts index 2703ed5..810865d 100644 --- a/src/engine/ad-console/features/missions/engine.ts +++ b/src/engine/ad-console/features/missions/engine.ts @@ -67,6 +67,7 @@ export function createMissionSession(): MissionSession { } export function startMission(missionId: string): MissionSession { + if (!getMission(missionId)) return createMissionSession(); return { missionId, currentStep: 0, From c870dd7dabf8c5d1d4dd3bdf8e0aab83adc80e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:38:22 +0000 Subject: [PATCH 09/13] fix: generateReport now produces rows for searchTerm/placement types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only 'campaign'/'adGroup'/'target' were implemented; the two other valid, user-selectable ReportTypes silently returned rows: [] with a status of 'completed' and no error — requesting a searchTerm or placement report produced an apparently successful but empty export. ReportRow has no per-type schema, so the existing simulated-row generator now covers every type in REPORT_TYPES instead of a hardcoded subset. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../features/reports/__tests__/engine.test.ts | 10 ++++++++++ src/engine/ad-console/features/reports/engine.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/engine/ad-console/features/reports/__tests__/engine.test.ts b/src/engine/ad-console/features/reports/__tests__/engine.test.ts index 3a8b0af..e39806e 100644 --- a/src/engine/ad-console/features/reports/__tests__/engine.test.ts +++ b/src/engine/ad-console/features/reports/__tests__/engine.test.ts @@ -27,6 +27,16 @@ describe('generateReport', () => { expect(r.rows).toHaveLength(0); }); + it('produces rows for searchTerm reports (previously silently empty)', () => { + const r = generateReport('searchTerm'); + expect(r.rows).toHaveLength(5); + }); + + it('produces rows for placement reports (previously silently empty)', () => { + const r = generateReport('placement'); + expect(r.rows).toHaveLength(5); + }); + it('computes derived KPIs per row', () => { const r = generateReport('campaign'); const row = r.rows[0]!; diff --git a/src/engine/ad-console/features/reports/engine.ts b/src/engine/ad-console/features/reports/engine.ts index 9ace79b..b0b9b38 100644 --- a/src/engine/ad-console/features/reports/engine.ts +++ b/src/engine/ad-console/features/reports/engine.ts @@ -26,7 +26,7 @@ export function generateReport(type: ReportType): Report { const rows: Report['rows'] = []; const now = new Date().toISOString(); - if (type === 'campaign' || type === 'adGroup' || type === 'target') { + if (REPORT_TYPES.includes(type)) { // Generate simulated report data for (let i = 0; i < 5; i++) { const clicks = Math.round(100 + Math.random() * 500); From 07cd72f6f5a4b57acacad41fc61a96e637f99abe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:41:12 +0000 Subject: [PATCH 10/13] fix: getNegativeCandidates default made its ACOS check unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exclusion condition was `st.orders >= minOrders && st.sales > 0` with a default minOrders of 0. Since orders can never be negative, `st.orders >= 0` is always true, so the condition collapsed to `st.sales > 0` — any search term with even $0.01 of sales was excluded before its ACOS was ever checked, hiding genuinely catastrophic-ACOS terms from negation. Mirrors the sibling getHarvestCandidates function: a single `st.orders >= minOrders` gate with minOrders defaulting to 1 (already converted at least once → protected from blanket negation; everything else proceeds to the ACOS check). Also adds the test coverage this function had none of. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../core/__tests__/campaignGoal.test.ts | 67 ++++++++++++++++++- src/engine/ad-console/core/engine/negative.ts | 4 +- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/engine/ad-console/core/__tests__/campaignGoal.test.ts b/src/engine/ad-console/core/__tests__/campaignGoal.test.ts index fa9bdc0..6d068a0 100644 --- a/src/engine/ad-console/core/__tests__/campaignGoal.test.ts +++ b/src/engine/ad-console/core/__tests__/campaignGoal.test.ts @@ -4,8 +4,29 @@ import { simulateDays, updateCampaignSettings, isFilteredByNegative, + getNegativeCandidates, + getHarvestCandidates, } from '../engine'; -import type { Campaign, Negative, Metrics } from '../types'; +import type { Campaign, Negative, Metrics, SearchTerm } from '../types'; + +function makeSearchTerm(over: Partial = {}): SearchTerm { + return { + id: over.id ?? 'ST1', + campaignId: 'C1', + adGroupId: 'AG1', + term: over.term ?? 'test term', + targetId: 'T1', + targetValue: 'test', + targetType: 'Keyword', + matchType: 'Broad', + impressions: over.impressions ?? 1000, + clicks: over.clicks ?? 20, + spend: over.spend ?? 50, + sales: over.sales ?? 0, + orders: over.orders ?? 0, + ...over, + }; +} function makeCampaign(over: Partial = {}): Campaign { return { @@ -131,6 +152,50 @@ describe('isFilteredByNegative', () => { }); }); +describe('getNegativeCandidates', () => { + it('flags a term with catastrophic ACOS and zero orders', () => { + const st = makeSearchTerm({ spend: 500, sales: 1, orders: 0, clicks: 20 }); + expect(getNegativeCandidates([st])).toEqual([st]); + }); + + it('flags a term with spend but zero sales at all (acos treated as maximal)', () => { + const st = makeSearchTerm({ spend: 50, sales: 0, orders: 0, clicks: 20 }); + expect(getNegativeCandidates([st])).toEqual([st]); + }); + + it('protects a term that has already converted (orders >= minOrders), regardless of current ACOS', () => { + const st = makeSearchTerm({ spend: 500, sales: 1, orders: 1, clicks: 20 }); + expect(getNegativeCandidates([st])).toEqual([]); + }); + + it('does not flag a term within the acceptable ACOS threshold', () => { + const st = makeSearchTerm({ spend: 20, sales: 100, orders: 0, clicks: 20 }); + expect(getNegativeCandidates([st])).toEqual([]); + }); + + it('does not flag a term below the minClicks/minSpend thresholds', () => { + const st = makeSearchTerm({ spend: 1, sales: 0, orders: 0, clicks: 1 }); + expect(getNegativeCandidates([st])).toEqual([]); + }); +}); + +describe('getHarvestCandidates', () => { + it('flags a term with good ACOS but no orders yet', () => { + const st = makeSearchTerm({ spend: 20, sales: 100, orders: 0, clicks: 20 }); + expect(getHarvestCandidates([st])).toEqual([st]); + }); + + it('excludes a term that has already converted', () => { + const st = makeSearchTerm({ spend: 20, sales: 100, orders: 1, clicks: 20 }); + expect(getHarvestCandidates([st])).toEqual([]); + }); + + it('excludes a term with poor ACOS', () => { + const st = makeSearchTerm({ spend: 500, sales: 100, orders: 0, clicks: 20 }); + expect(getHarvestCandidates([st])).toEqual([]); + }); +}); + describe('updateCampaignSettings - creativeStatus', () => { it('updates creativeStatus', () => { const c = makeCampaign({ creativeStatus: 'Rejected' }); diff --git a/src/engine/ad-console/core/engine/negative.ts b/src/engine/ad-console/core/engine/negative.ts index 8b457ad..779d189 100644 --- a/src/engine/ad-console/core/engine/negative.ts +++ b/src/engine/ad-console/core/engine/negative.ts @@ -157,9 +157,9 @@ export function getNegativeCandidates( searchTerms: SearchTerm[], opts: SearchTermFilterOptions = {}, ): SearchTerm[] { - const { minSpend = 10, minClicks = 5, maxAcos = 50, minOrders = 0 } = opts; + const { minSpend = 10, minClicks = 5, maxAcos = 50, minOrders = 1 } = opts; return searchTerms.filter((st) => { - if (st.orders >= minOrders && st.sales > 0) return false; + if (st.orders >= minOrders) return false; // Already converting — don't blanket-negate if (st.clicks < minClicks) return false; if (st.spend < minSpend) return false; const acos = st.sales > 0 ? (st.spend / st.sales) * 100 : 100; From 2d20aaf2e0152435f01e8a5643b5f986d308541d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:44:07 +0000 Subject: [PATCH 11/13] fix: guard against corrupted JSON columns crashing campaign reads GET /api/campaigns, GET /api/campaigns/[id], and GET /api/sync all called JSON.parse directly on 10 stored JSON columns with no try/catch, while the sync write path is careful to validate input. A single row with a corrupted or truncated JSON value (from a prior bug, a direct DB edit, or column truncation) threw an uncaught SyntaxError, 500ing the entire list rather than just that row. Added a shared safeJsonParse helper that falls back to the same empty defaults already used for null columns, so one bad row no longer takes down every other campaign in the response. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- src/app/api/campaigns/[id]/route.ts | 24 ++++----- src/app/api/campaigns/route.ts | 24 ++++----- src/app/api/sync/__tests__/route.test.ts | 62 ++++++++++++++++++++++++ src/app/api/sync/route.ts | 24 ++++----- src/lib/__tests__/json.test.ts | 19 ++++++++ src/lib/json.ts | 15 ++++++ 6 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 src/lib/__tests__/json.test.ts create mode 100644 src/lib/json.ts diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts index 12299fe..203758e 100644 --- a/src/app/api/campaigns/[id]/route.ts +++ b/src/app/api/campaigns/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { auth } from '@/lib/auth'; +import { safeJsonParse } from '@/lib/json'; export async function GET( request: Request, @@ -23,19 +24,20 @@ export async function GET( return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - // Parse JSON fields + // Parse JSON fields — a corrupted value falls back to an empty default + // instead of throwing and 500ing the request. const parsed = { ...campaign, - placements: campaign.placements ? JSON.parse(campaign.placements) : null, - products: campaign.products ? JSON.parse(campaign.products) : [], - creative: campaign.creative ? JSON.parse(campaign.creative) : null, - metrics: campaign.metrics ? JSON.parse(campaign.metrics) : null, - adGroups: campaign.adGroups ? JSON.parse(campaign.adGroups) : [], - targets: campaign.targets ? JSON.parse(campaign.targets) : [], - searchTerms: campaign.searchTerms ? JSON.parse(campaign.searchTerms) : [], - negatives: campaign.negatives ? JSON.parse(campaign.negatives) : [], - budgetRules: campaign.budgetRules ? JSON.parse(campaign.budgetRules) : [], - history: campaign.history ? JSON.parse(campaign.history) : [], + placements: safeJsonParse(campaign.placements, null), + products: safeJsonParse(campaign.products, []), + creative: safeJsonParse(campaign.creative, null), + metrics: safeJsonParse(campaign.metrics, null), + adGroups: safeJsonParse(campaign.adGroups, []), + targets: safeJsonParse(campaign.targets, []), + searchTerms: safeJsonParse(campaign.searchTerms, []), + negatives: safeJsonParse(campaign.negatives, []), + budgetRules: safeJsonParse(campaign.budgetRules, []), + history: safeJsonParse(campaign.history, []), }; return NextResponse.json(parsed); diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index 56217f3..1a267a8 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { auth } from '@/lib/auth'; +import { safeJsonParse } from '@/lib/json'; export async function GET() { const session = await auth(); @@ -13,19 +14,20 @@ export async function GET() { orderBy: { createdAt: 'desc' }, }); - // Parse JSON fields + // Parse JSON fields — a corrupted value in one row falls back to an + // empty default instead of throwing and 500ing the entire list. const parsed = campaigns.map((c: any) => ({ ...c, - placements: c.placements ? JSON.parse(c.placements) : null, - products: c.products ? JSON.parse(c.products) : [], - creative: c.creative ? JSON.parse(c.creative) : null, - metrics: c.metrics ? JSON.parse(c.metrics) : null, - adGroups: c.adGroups ? JSON.parse(c.adGroups) : [], - targets: c.targets ? JSON.parse(c.targets) : [], - searchTerms: c.searchTerms ? JSON.parse(c.searchTerms) : [], - negatives: c.negatives ? JSON.parse(c.negatives) : [], - budgetRules: c.budgetRules ? JSON.parse(c.budgetRules) : [], - history: c.history ? JSON.parse(c.history) : [], + placements: safeJsonParse(c.placements, null), + products: safeJsonParse(c.products, []), + creative: safeJsonParse(c.creative, null), + metrics: safeJsonParse(c.metrics, null), + adGroups: safeJsonParse(c.adGroups, []), + targets: safeJsonParse(c.targets, []), + searchTerms: safeJsonParse(c.searchTerms, []), + negatives: safeJsonParse(c.negatives, []), + budgetRules: safeJsonParse(c.budgetRules, []), + history: safeJsonParse(c.history, []), })); return NextResponse.json(parsed); diff --git a/src/app/api/sync/__tests__/route.test.ts b/src/app/api/sync/__tests__/route.test.ts index 0d71e69..c5b3912 100644 --- a/src/app/api/sync/__tests__/route.test.ts +++ b/src/app/api/sync/__tests__/route.test.ts @@ -220,4 +220,66 @@ describe('GET /api/sync', () => { const res = await GET(); expect(res.status).toBe(401); }); + + it('falls back to empty defaults for a row with corrupted JSON instead of 500ing the whole list', async () => { + prismaMock.campaign.findMany.mockResolvedValueOnce([ + { + campaignId: 'corrupted', + type: 'SP', + name: 'Corrupted Row', + portfolio: null, + status: 'Enabled', + dailyBudget: 25, + defaultBid: 0.75, + startDate: null, + endDate: null, + targetingMode: null, + adFormat: null, + campaignGoal: null, + bidStrategy: null, + placements: '{not valid json', + products: null, + creative: null, + metrics: null, + adGroups: '{also not valid', + targets: null, + searchTerms: null, + negatives: null, + budgetRules: null, + history: null, + }, + { + campaignId: 'healthy', + type: 'SP', + name: 'Healthy Row', + portfolio: null, + status: 'Enabled', + dailyBudget: 25, + defaultBid: 0.75, + startDate: null, + endDate: null, + targetingMode: null, + adFormat: null, + campaignGoal: null, + bidStrategy: null, + placements: null, + products: null, + creative: null, + metrics: null, + adGroups: null, + targets: null, + searchTerms: null, + negatives: null, + budgetRules: null, + history: null, + }, + ]); + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(2); + expect(body[0].placements).toEqual({ top: 0, product: 0, rest: 0 }); + expect(body[0].adGroups).toEqual([]); + expect(body[1].id).toBe('healthy'); + }); }); diff --git a/src/app/api/sync/route.ts b/src/app/api/sync/route.ts index 1570fe1..1184822 100644 --- a/src/app/api/sync/route.ts +++ b/src/app/api/sync/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import type { Prisma } from '@/generated/prisma/client'; import { prisma } from '@/lib/prisma'; import { auth } from '@/lib/auth'; +import { safeJsonParse } from '@/lib/json'; /** * Wire shape of a campaign as the browser sends it in the sync payload. @@ -133,7 +134,8 @@ export async function GET() { orderBy: { createdAt: 'desc' }, }); - // Parse JSON fields + // Parse JSON fields — a corrupted value in one row falls back to an + // empty default instead of throwing and 500ing the entire list. const parsed = campaigns.map((c: any) => ({ id: c.campaignId, type: c.type, @@ -148,16 +150,16 @@ export async function GET() { adFormat: c.adFormat, campaignGoal: c.campaignGoal, bidStrategy: c.bidStrategy, - placements: c.placements ? JSON.parse(c.placements) : { top: 0, product: 0, rest: 0 }, - products: c.products ? JSON.parse(c.products) : [], - creative: c.creative ? JSON.parse(c.creative) : null, - metrics: c.metrics ? JSON.parse(c.metrics) : { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }, - adGroups: c.adGroups ? JSON.parse(c.adGroups) : [], - targets: c.targets ? JSON.parse(c.targets) : [], - searchTerms: c.searchTerms ? JSON.parse(c.searchTerms) : [], - negatives: c.negatives ? JSON.parse(c.negatives) : [], - budgetRules: c.budgetRules ? JSON.parse(c.budgetRules) : [], - history: c.history ? JSON.parse(c.history) : [], + placements: safeJsonParse(c.placements, { top: 0, product: 0, rest: 0 }), + products: safeJsonParse(c.products, []), + creative: safeJsonParse(c.creative, null), + metrics: safeJsonParse(c.metrics, { impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 }), + adGroups: safeJsonParse(c.adGroups, []), + targets: safeJsonParse(c.targets, []), + searchTerms: safeJsonParse(c.searchTerms, []), + negatives: safeJsonParse(c.negatives, []), + budgetRules: safeJsonParse(c.budgetRules, []), + history: safeJsonParse(c.history, []), })); return NextResponse.json(parsed); diff --git a/src/lib/__tests__/json.test.ts b/src/lib/__tests__/json.test.ts new file mode 100644 index 0000000..0c480c9 --- /dev/null +++ b/src/lib/__tests__/json.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { safeJsonParse } from '../json'; + +describe('safeJsonParse', () => { + it('parses valid JSON', () => { + expect(safeJsonParse('{"a":1}', null)).toEqual({ a: 1 }); + }); + + it('returns the fallback for null/undefined/empty input', () => { + expect(safeJsonParse(null, [])).toEqual([]); + expect(safeJsonParse(undefined, [])).toEqual([]); + expect(safeJsonParse('', [])).toEqual([]); + }); + + it('returns the fallback instead of throwing on corrupted JSON', () => { + expect(() => safeJsonParse('{not valid json', [])).not.toThrow(); + expect(safeJsonParse('{not valid json', ['fallback'])).toEqual(['fallback']); + }); +}); diff --git a/src/lib/json.ts b/src/lib/json.ts new file mode 100644 index 0000000..edb7900 --- /dev/null +++ b/src/lib/json.ts @@ -0,0 +1,15 @@ +/** + * Safe JSON parsing for data read back from storage (DB columns, etc). + * + * Campaign JSON columns are trusted at write time but read unconditionally + * on every list/detail fetch — a single corrupted or truncated value must + * not take down the whole response. + */ +export function safeJsonParse(value: string | null | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} From 8e16ff33541f84d4932896efec17d1e0f4bf3526 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:45:22 +0000 Subject: [PATCH 12/13] fix: validate email format and password length on registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration accepted any non-empty password (a single character was enough) and any string as an "email" with no format check. Added a basic email-format check and an 8-character minimum password length. Left the user-enumeration behavior (differing response for an existing email) unchanged for now — fixing that properly means changing the registration UX to not confirm success/failure by status code, which is a larger behavioral change than this pass, and lower real-world severity for an offline training simulator with no real user data at stake. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../api/auth/register/__tests__/route.test.ts | 64 +++++++++++++++++++ src/app/api/auth/register/route.ts | 14 ++++ 2 files changed, 78 insertions(+) create mode 100644 src/app/api/auth/register/__tests__/route.test.ts diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts new file mode 100644 index 0000000..566261e --- /dev/null +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for /api/auth/register input validation. + * + * The route previously accepted any non-empty password (even 1 character) + * and any string containing '@' as an "email", with no format check. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { prismaMock, bcryptMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + }, + }, + bcryptMock: { + hash: vi.fn(), + }, +})); + +vi.mock('@/lib/prisma', () => ({ + prisma: prismaMock, +})); + +vi.mock('bcryptjs', () => ({ + default: bcryptMock, +})); + +import { POST } from '../route'; + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/auth/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.user.findUnique.mockResolvedValue(null); + prismaMock.user.create.mockResolvedValue({ id: 'user-1' }); + bcryptMock.hash.mockResolvedValue('hashed'); +}); + +describe('POST /api/auth/register', () => { + it('rejects a password shorter than 8 characters', async () => { + const res = await POST(makeRequest({ email: 'a@b.com', password: '1' })); + expect(res.status).toBe(400); + expect(prismaMock.user.create).not.toHaveBeenCalled(); + }); + + it('rejects a malformed email', async () => { + const res = await POST(makeRequest({ email: 'not-an-email', password: 'longenough' })); + expect(res.status).toBe(400); + expect(prismaMock.user.create).not.toHaveBeenCalled(); + }); + + it('accepts a valid email and an 8+ character password', async () => { + const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); + expect(res.status).toBe(201); + expect(prismaMock.user.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 6ce8451..2945c6c 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -13,6 +13,20 @@ export async function POST(request: Request) { ); } + if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { error: 'Enter a valid email address' }, + { status: 400 } + ); + } + + if (typeof password !== 'string' || password.length < 8) { + return NextResponse.json( + { error: 'Password must be at least 8 characters' }, + { status: 400 } + ); + } + const existingUser = await prisma.user.findUnique({ where: { email }, }); From 5316dde01ac87b34b4c2eb5ad29903e47526feae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:47:09 +0000 Subject: [PATCH 13/13] fix: simulateDays fails fast on invalid days instead of corrupting metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No validation existed on days — a negative value flowed unclamped into spend (Math.min picked the more-negative term), decreasing a campaign's cumulative spend/impressions/clicks below their prior values with a nonsensical history entry. Not reachable through the current UI (every call site uses the days=7 default), but it's a public engine function with no guard, unlike the rest of the engine's fail-fast convention. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../ad-console/core/__tests__/simulation.test.ts | 10 ++++++++++ src/engine/ad-console/core/simulation.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/engine/ad-console/core/__tests__/simulation.test.ts b/src/engine/ad-console/core/__tests__/simulation.test.ts index e69028e..6a2a04b 100644 --- a/src/engine/ad-console/core/__tests__/simulation.test.ts +++ b/src/engine/ad-console/core/__tests__/simulation.test.ts @@ -170,4 +170,14 @@ describe('simulateDays', () => { const unique = new Set(terms); expect(terms.length).toBe(unique.size); }); + + it('fails fast on a negative days value instead of corrupting metrics', () => { + const c = makeCampaign(); + expect(() => simulateDays([c], -7)).toThrow(); + }); + + it('fails fast on a NaN days value', () => { + const c = makeCampaign(); + expect(() => simulateDays([c], NaN)).toThrow(); + }); }); diff --git a/src/engine/ad-console/core/simulation.ts b/src/engine/ad-console/core/simulation.ts index 178c3c5..c3dde86 100644 --- a/src/engine/ad-console/core/simulation.ts +++ b/src/engine/ad-console/core/simulation.ts @@ -6,8 +6,10 @@ import type { Campaign, Metrics, SearchTerm } from './types'; import { generateId, metricDefaults, isFilteredByNegative } from './engine'; import { generateSearchTermsForTarget } from './engine/search-term-generator'; +import { assertFiniteNonNegative } from '../../../lib/validation'; export function simulateDays(campaigns: Campaign[], days: number = 7): Campaign[] { + assertFiniteNonNegative('days', days); const avgPrice = 29.99; return campaigns.map((c) => { if (c.status !== 'Enabled') return c;