fix: Bug 1+2+3 — SQL Cross-Join, PATCH 404 vs 400, API-Test-Skip

- getCampaignRecipients: EXISTS-Subquery statt Cross-Join verhindert Mehrfachversand
- updateCampaign: SELECT vor UPDATE unterscheidet 'nicht gefunden' (404) von 'nicht im Draft' (400)
- campaigns-api.test.ts: describe.skip entfernt, Mocks für DB-Abhängigkeiten ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:34:18 +00:00
parent ca0c65352e
commit e5db71ead1
4 changed files with 114 additions and 33 deletions

View File

@@ -15,6 +15,7 @@ import {
updateCampaign,
listCampaigns,
updateCampaignStatus,
getCampaignRecipients,
} from './campaigns'
const mockRow = {
@@ -31,7 +32,7 @@ const mockRow = {
}
describe('createCampaign', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => mockClient.query.mockReset())
it('gibt erstellte Kampagne zurück', async () => {
mockClient.query.mockResolvedValueOnce([mockRow])
@@ -55,7 +56,7 @@ describe('createCampaign', () => {
})
describe('getCampaign', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => mockClient.query.mockReset())
it('gibt Kampagne zurück wenn gefunden', async () => {
mockClient.query.mockResolvedValueOnce([mockRow])
@@ -72,7 +73,7 @@ describe('getCampaign', () => {
})
describe('listCampaigns', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => mockClient.query.mockReset())
it('gibt leeres Array zurück wenn keine Kampagnen', async () => {
mockClient.query.mockResolvedValueOnce([])
@@ -89,10 +90,12 @@ describe('listCampaigns', () => {
})
describe('updateCampaign', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => mockClient.query.mockReset())
it('aktualisiert nur übergebene Felder', async () => {
mockClient.query.mockResolvedValueOnce([{ ...mockRow, name: 'Neu' }])
mockClient.query
.mockResolvedValueOnce([{ id: 'uuid-1', status: 'draft' }]) // SELECT Existenz-Check
.mockResolvedValueOnce([{ ...mockRow, name: 'Neu' }]) // UPDATE RETURNING
const result = await updateCampaign('tenant1', 'uuid-1', { name: 'Neu' })
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.name).toBe('Neu')
@@ -103,16 +106,54 @@ describe('updateCampaign', () => {
expect(result.ok).toBe(false)
})
it('gibt err zurück wenn Kampagne nicht im Draft-Status', async () => {
mockClient.query.mockResolvedValueOnce([])
it('gibt err mit "nicht gefunden" zurück wenn Kampagne nicht existiert', async () => {
// Bug 2: muss "nicht gefunden" melden, nicht "Draft-Status"
mockClient.query.mockResolvedValueOnce([]) // SELECT: nicht gefunden
const result = await updateCampaign('tenant1', 'uuid-nicht-existent', { name: 'Neu' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toContain('nicht gefunden')
})
it('gibt err mit "Draft-Status" zurück wenn Kampagne nicht im Draft ist', async () => {
mockClient.query.mockResolvedValueOnce([{ id: 'uuid-1', status: 'sent' }]) // SELECT: gefunden, aber sent
const result = await updateCampaign('tenant1', 'uuid-1', { name: 'Neu' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toContain('Draft-Status')
})
})
describe('getCampaignRecipients', () => {
beforeEach(() => mockClient.query.mockReset())
it('nutzt EXISTS-Subquery statt Cross-Join', async () => {
mockClient.query.mockResolvedValueOnce([{ email: 'a@example.com' }])
await getCampaignRecipients('tenant1', 'campaign-1')
const [sql] = mockClient.query.mock.calls[0] as [string, unknown[]]
// Bug 1: muss EXISTS statt JOIN verwenden um Duplikate zu vermeiden
expect(sql).toContain('EXISTS')
expect(sql).not.toMatch(/JOIN.*campaign_recipients.*ON.*campaign_id\s*=\s*\$1/i)
})
it('gibt deduplizierte E-Mail-Adressen zurück', async () => {
mockClient.query.mockResolvedValueOnce([
{ email: 'a@example.com' },
{ email: 'b@example.com' },
])
const result = await getCampaignRecipients('tenant1', 'campaign-1')
expect(result.ok).toBe(true)
if (result.ok) expect(result.data).toHaveLength(2)
})
it('gibt leeres Array zurück wenn keine Empfänger', async () => {
mockClient.query.mockResolvedValueOnce([])
const result = await getCampaignRecipients('tenant1', 'campaign-1')
expect(result.ok).toBe(true)
if (result.ok) expect(result.data).toEqual([])
})
})
describe('updateCampaignStatus', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => mockClient.query.mockReset())
it('setzt Status auf scheduled', async () => {
mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'scheduled' }])

View File

@@ -79,15 +79,48 @@ export async function updateCampaign(
if (fields.length === 0) return err(new Error('Keine Felder zum Aktualisieren'))
try {
values.push(id)
const rows = await withTenant(tenantId, (client) =>
client.query(
`UPDATE campaigns SET ${fields.join(', ')} WHERE id = $${idx} AND status = 'draft' RETURNING *`,
return await withTenant(tenantId, async (client) => {
// Bug 2: Existenz und Status getrennt prüfen um korrekten Fehler zu melden
const existing = await client.query<{ id: string; status: string }>(
'SELECT id, status FROM campaigns WHERE id = $1',
[id]
)
if (existing.length === 0) return err(new Error('Kampagne nicht gefunden'))
if (existing[0].status !== 'draft') return err(new Error('Kampagne nicht im Draft-Status'))
values.push(id)
const rows = await client.query(
`UPDATE campaigns SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
)
if (rows.length === 0) return err(new Error('Kampagne nicht gefunden'))
return ok(rowToCampaign(rows[0]))
})
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}
}
export async function getCampaignRecipients(
tenantId: string,
campaignId: string
): Promise<Result<{ email: string }[]>> {
try {
// Bug 1: EXISTS-Subquery verhindert Duplikate durch kartesisches Produkt
const rows = await withTenant(tenantId, (client) =>
client.query<{ email: string }>(
`SELECT s.email
FROM subscribers s
WHERE s.status = 'active'
AND EXISTS (
SELECT 1 FROM campaign_recipients cr
WHERE cr.campaign_id = $1
AND (cr.list_id IS NULL OR cr.list_id = s.list_id)
)`,
[campaignId]
)
)
if (rows.length === 0) return err(new Error('Kampagne nicht im Draft-Status'))
return ok(rowToCampaign(rows[0]))
return ok(rows)
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
}