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 }, }); 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..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); @@ -86,7 +88,7 @@ export async function PUT( } const campaign = await prisma.campaign.update({ - where: { id }, + where: { id, userId: session.user.id }, data: updateData, }); @@ -116,7 +118,7 @@ export async function DELETE( } await prisma.campaign.delete({ - where: { id }, + where: { id, userId: session.user.id }, }); return NextResponse.json({ message: 'Deleted' }); 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/engine/ad-console/__tests__/feature-stores.test.ts b/src/engine/ad-console/__tests__/feature-stores.test.ts index 464b8f0..b932112 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; @@ -65,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', () => { @@ -76,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/__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/core/__tests__/campaignGoal.test.ts b/src/engine/ad-console/core/__tests__/campaignGoal.test.ts index 36b46d8..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' }); @@ -164,4 +229,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/__tests__/engine.test.ts b/src/engine/ad-console/core/__tests__/engine.test.ts index 692ab48..3e9bb92 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', () => { @@ -174,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/__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/engine/campaign.ts b/src/engine/ad-console/core/engine/campaign.ts index 20fe41d..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'; @@ -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({}), })), }); @@ -257,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/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; 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'}`, ], }; } 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; 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; 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, diff --git a/src/engine/ad-console/features/profiles/store.ts b/src/engine/ad-console/features/profiles/store.ts index 85bef27..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), })); @@ -41,10 +45,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, }; }); }, 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); 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] })); }, 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), 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; + } +}