From 11bf3e771a8a75938790c78b79e0ea73d2236ded Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 4 Apr 2026 07:56:30 -0700 Subject: [PATCH] =?UTF-8?q?test(seo-specific):=20=E2=9C=85=20Update=20SEO-?= =?UTF-8?q?specific=20test=20suites=20with=20API=20client,=20fixtures,=20r?= =?UTF-8?q?ankings=20service,=20core=20service,=20and=20end-to-end=20valid?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../backend-api/test/seo-api-client.spec.ts | 244 +++++++++ .../backend-api/test/seo-fixtures.ts | 131 +++++ .../test/seo-rankings-service.spec.ts | 310 +++++++++++ .../backend-api/test/seo-service.spec.ts | 416 ++++++++++++++ .../backend-api/test/seo.e2e-spec.ts | 510 ++++++++++++++++++ 5 files changed, 1611 insertions(+) create mode 100644 features/platform-analytics/backend-api/test/seo-api-client.spec.ts create mode 100644 features/platform-analytics/backend-api/test/seo-fixtures.ts create mode 100644 features/platform-analytics/backend-api/test/seo-rankings-service.spec.ts create mode 100644 features/platform-analytics/backend-api/test/seo-service.spec.ts create mode 100644 features/platform-analytics/backend-api/test/seo.e2e-spec.ts diff --git a/features/platform-analytics/backend-api/test/seo-api-client.spec.ts b/features/platform-analytics/backend-api/test/seo-api-client.spec.ts new file mode 100644 index 000000000..045ae10cd --- /dev/null +++ b/features/platform-analytics/backend-api/test/seo-api-client.spec.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; + +import { SeoApiClient } from '@/modules/seo/seo-api.client'; + +vi.mock('@lilith/service-registry', () => ({ + buildDeploymentRegistry: () => ({ + services: new Map(), + }), +})); + +describe('SeoApiClient', () => { + let client: SeoApiClient; + let fetchSpy: ReturnType; + + beforeEach(async () => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SeoApiClient, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, fallback: string) => { + if (key === 'SEO_API_URL') return 'http://seo-test:3014'; + return fallback; + }), + }, + }, + ], + }).compile(); + + client = module.get(SeoApiClient); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockJsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers({ 'Content-Type': 'application/json' }), + } as Response; + } + + // ========================================================================== + // getCampaigns + // ========================================================================== + + describe('getCampaigns', () => { + it('fetches campaigns and maps stats fields', async () => { + const apiResponse = [ + { + id: 'c1', + name: 'Winter Push', + status: 'active', + stats: { total: 25, generated: 20, published: 18 }, + }, + { + id: 'c2', + name: 'Spring Launch', + status: 'draft', + stats: { total: 10, generated: 5, published: 0 }, + }, + ]; + fetchSpy.mockResolvedValue(mockJsonResponse(apiResponse)); + + const result = await client.getCampaigns(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'c1', + name: 'Winter Push', + status: 'active', + targetCount: 25, + generatedCount: 20, + publishedCount: 18, + }); + expect(result[1].targetCount).toBe(10); + }); + + it('calls correct URL', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await client.getCampaigns(); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://seo-test:3014/campaigns', + expect.objectContaining({ headers: { Accept: 'application/json' } }), + ); + }); + + it('handles missing stats gracefully with zero defaults', async () => { + const apiResponse = [{ id: 'c1', name: 'No Stats', status: 'active', stats: null }]; + fetchSpy.mockResolvedValue(mockJsonResponse(apiResponse)); + + const result = await client.getCampaigns(); + + expect(result[0].targetCount).toBe(0); + expect(result[0].generatedCount).toBe(0); + expect(result[0].publishedCount).toBe(0); + }); + + it('throws on non-OK response', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse('Not Found', 404)); + + await expect(client.getCampaigns()).rejects.toThrow(/SEO API error \(404\)/); + }); + }); + + // ========================================================================== + // getCampaignTargets + // ========================================================================== + + describe('getCampaignTargets', () => { + it('fetches targets for a campaign', async () => { + const targets = [ + { id: 't1', campaignId: 'c1', domain: 'atlilith.com', path: '/blog/test', categorySlug: 'seo', locationId: 'us', status: 'published', contentId: 'ct1' }, + ]; + fetchSpy.mockResolvedValue(mockJsonResponse(targets)); + + const result = await client.getCampaignTargets('c1'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/blog/test'); + }); + + it('calls correct URL with campaign ID', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await client.getCampaignTargets('abc-123'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://seo-test:3014/campaigns/abc-123/targets', + expect.any(Object), + ); + }); + + it('throws on server error', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse('Internal Error', 500)); + + await expect(client.getCampaignTargets('c1')).rejects.toThrow(/SEO API error \(500\)/); + }); + }); + + // ========================================================================== + // getCachedPages + // ========================================================================== + + describe('getCachedPages', () => { + it('fetches cached pages without domain filter', async () => { + const pages = [ + { id: 'p1', domain: 'atlilith.com', path: '/blog', locale: 'en', status: 'published', createdAt: '2026-01-01', updatedAt: '2026-01-15' }, + ]; + fetchSpy.mockResolvedValue(mockJsonResponse(pages)); + + const result = await client.getCachedPages(); + + expect(result).toHaveLength(1); + expect(result[0].domain).toBe('atlilith.com'); + }); + + it('passes domain as query parameter when provided', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await client.getCachedPages('atlilith.com'); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get('domain')).toBe('atlilith.com'); + }); + + it('does not include domain param when undefined', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await client.getCachedPages(); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.has('domain')).toBe(false); + }); + + it('calls /content endpoint', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await client.getCachedPages(); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('/content'); + }); + + it('throws on non-OK response', async () => { + fetchSpy.mockResolvedValue(mockJsonResponse('Unauthorized', 401)); + + await expect(client.getCachedPages()).rejects.toThrow(/SEO API error \(401\)/); + }); + }); + + // ========================================================================== + // onModuleInit + // ========================================================================== + + describe('onModuleInit', () => { + it('logs base URL on init without throwing', () => { + expect(() => client.onModuleInit()).not.toThrow(); + }); + }); + + // ========================================================================== + // Config fallback + // ========================================================================== + + describe('config', () => { + it('uses fallback URL when SEO_API_URL not set', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SeoApiClient, + { + provide: ConfigService, + useValue: { + get: vi.fn((_key: string, fallback: string) => fallback), + }, + }, + ], + }).compile(); + + const fallbackClient = module.get(SeoApiClient); + fetchSpy.mockResolvedValue(mockJsonResponse([])); + + await fallbackClient.getCampaigns(); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('http://localhost:3014'); + }); + }); +}); diff --git a/features/platform-analytics/backend-api/test/seo-fixtures.ts b/features/platform-analytics/backend-api/test/seo-fixtures.ts new file mode 100644 index 000000000..bc1c19620 --- /dev/null +++ b/features/platform-analytics/backend-api/test/seo-fixtures.ts @@ -0,0 +1,131 @@ +import type { ChannelMetrics, PageMetrics } from '@/modules/analytics-gateway/analytics-api.client'; +import type { CampaignDto, CampaignTargetDto, CachedPageDto } from '@/modules/seo/seo-api.client'; + +/** + * Creates a mock ChannelMetrics entry for organic search. + */ +export function createOrganicChannel(overrides: Partial = {}): ChannelMetrics { + return { + channel: 'Organic Search', + sessions: 8450, + users: 5632, + newUsers: 2103, + engagementRate: 68.5, + conversionRate: 3.2, + revenue: 12500, + ...overrides, + }; +} + +/** + * Creates a mock ChannelMetrics entry for a non-organic channel. + */ +export function createDirectChannel(overrides: Partial = {}): ChannelMetrics { + return { + channel: 'Direct', + sessions: 5680, + users: 3210, + newUsers: 980, + engagementRate: 55.2, + conversionRate: 2.1, + revenue: 8400, + ...overrides, + }; +} + +/** + * Creates a mock PageMetrics entry. + */ +export function createPageMetric(overrides: Partial = {}): PageMetrics { + return { + path: '/blog/test-post', + title: 'Test Post', + views: 1200, + uniqueViews: 980, + avgTimeOnPage: 145, + bounceRate: 42.3, + exitRate: 35.8, + ...overrides, + }; +} + +/** + * Creates a batch of page metrics with varied paths. + */ +export function createPageMetricBatch(count: number): PageMetrics[] { + const paths = ['/blog/seo-guide', '/pricing', '/about', '/docs/getting-started', '/features']; + return Array.from({ length: count }, (_, i) => + createPageMetric({ + path: paths[i % paths.length], + title: `Page ${i}`, + views: 1000 - i * 100, + uniqueViews: 800 - i * 80, + avgTimeOnPage: 120 + i * 15, + bounceRate: 35 + i * 3, + }), + ); +} + +/** + * Creates a mock CachedPageDto from seo-service. + */ +export function createCachedPage(overrides: Partial = {}): CachedPageDto { + return { + id: 'cached-1', + domain: 'atlilith.com', + path: '/blog/seo-guide', + locale: 'en', + status: 'published', + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-20T14:30:00Z', + ...overrides, + }; +} + +/** + * Creates a mock CampaignDto from seo-service. + */ +export function createCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'campaign-1', + name: 'Winter SEO Push', + status: 'active', + targetCount: 25, + generatedCount: 20, + publishedCount: 18, + ...overrides, + }; +} + +/** + * Creates a mock CampaignTargetDto from seo-service. + */ +export function createCampaignTarget(overrides: Partial = {}): CampaignTargetDto { + return { + id: 'target-1', + campaignId: 'campaign-1', + domain: 'atlilith.com', + path: '/blog/seo-guide', + categorySlug: 'seo', + locationId: 'us-east', + status: 'published', + contentId: 'content-1', + ...overrides, + }; +} + +/** + * Creates mock session metrics response. + */ +export function createSessionMetrics(overrides: Record = {}) { + return { + totalSessions: 18921, + avgSessionDuration: 245, + avgPageViews: 3.2, + bounceRate: 42.3, + engagementRate: 68.5, + newUserRate: 36.0, + conversionRate: 4.3, + ...overrides, + }; +} diff --git a/features/platform-analytics/backend-api/test/seo-rankings-service.spec.ts b/features/platform-analytics/backend-api/test/seo-rankings-service.spec.ts new file mode 100644 index 000000000..16ea713f3 --- /dev/null +++ b/features/platform-analytics/backend-api/test/seo-rankings-service.spec.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { SeoRankingsService } from '@/modules/seo/seo-rankings.service'; +import { SeoRankingSnapshot } from '@/entities'; +import { createMockRepository, type MockRepository, type MockQueryBuilder } from './mocks'; + +vi.mock('@lilith/service-registry', () => ({ + buildDeploymentRegistry: () => ({ + services: new Map(), + }), +})); + +describe('SeoRankingsService', () => { + let service: SeoRankingsService; + let repo: MockRepository; + let qb: MockQueryBuilder; + + beforeEach(async () => { + repo = createMockRepository(); + qb = repo.queryBuilder; + // Add setParameter (not in shared mock but used by SeoRankingsService) + (qb as Record>).setParameter = vi.fn().mockReturnValue(qb); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SeoRankingsService, + { provide: getRepositoryToken(SeoRankingSnapshot), useValue: repo }, + ], + }).compile(); + + service = module.get(SeoRankingsService); + }); + + // ========================================================================== + // getRankings + // ========================================================================== + + describe('getRankings', () => { + const query = { domain: 'atlilith.com', startDate: '2026-01-01', endDate: '2026-01-31' }; + + it('returns rankings with computed fields', async () => { + qb.getRawMany.mockResolvedValue([ + { + path: '/blog/seo-guide', + avgPosition: '3.45', + totalImpressions: '5000', + totalClicks: '250', + avgCtr: '0.05', + firstHalfImpressions: '2000', + secondHalfImpressions: '3000', + }, + ]); + + const result = await service.getRankings(query); + + expect(result.rankings).toHaveLength(1); + expect(result.total).toBe(1); + + const ranking = result.rankings[0]; + expect(ranking.path).toBe('/blog/seo-guide'); + expect(ranking.avgPosition).toBe(3.45); + expect(ranking.totalImpressions).toBe(5000); + expect(ranking.totalClicks).toBe(250); + expect(ranking.avgCtr).toBe(0.05); + expect(ranking.impressionsTrend).toBe(50); // (3000-2000)/2000 * 100 = 50% + }); + + it('returns zero impressionsTrend when firstHalf is zero', async () => { + qb.getRawMany.mockResolvedValue([ + { + path: '/new-page', + avgPosition: '10.0', + totalImpressions: '100', + totalClicks: '5', + avgCtr: '0.05', + firstHalfImpressions: '0', + secondHalfImpressions: '100', + }, + ]); + + const result = await service.getRankings(query); + + expect(result.rankings[0].impressionsTrend).toBe(0); + }); + + it('defaults limit to 50 when not provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getRankings({ domain: 'atlilith.com' }); + + expect(qb.limit).toHaveBeenCalledWith(50); + }); + + it('caps limit at 500', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getRankings({ domain: 'atlilith.com', limit: '1000' }); + + expect(qb.limit).toHaveBeenCalledWith(500); + }); + + it('sets domain and date range in query builder', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getRankings(query); + + expect(qb.where).toHaveBeenCalledWith('s.domain = :domain', { domain: 'atlilith.com' }); + expect(qb.andWhere).toHaveBeenCalled(); + }); + + it('returns empty rankings array when no data', async () => { + qb.getRawMany.mockResolvedValue([]); + + const result = await service.getRankings(query); + + expect(result.rankings).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('defaults to 30-day range when dates not provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getRankings({ domain: 'atlilith.com' }); + + expect(qb.andWhere).toHaveBeenCalled(); + }); + + it('handles null/missing raw values gracefully', async () => { + qb.getRawMany.mockResolvedValue([ + { + path: '/test', + avgPosition: null, + totalImpressions: null, + totalClicks: null, + avgCtr: null, + firstHalfImpressions: null, + secondHalfImpressions: null, + }, + ]); + + const result = await service.getRankings(query); + const ranking = result.rankings[0]; + + expect(ranking.avgPosition).toBe(0); + expect(ranking.totalImpressions).toBe(0); + expect(ranking.totalClicks).toBe(0); + expect(ranking.avgCtr).toBe(0); + expect(ranking.impressionsTrend).toBe(0); + }); + }); + + // ========================================================================== + // getKeywords + // ========================================================================== + + describe('getKeywords', () => { + const query = { domain: 'atlilith.com', startDate: '2026-01-01', endDate: '2026-01-31' }; + + it('returns keywords with aggregated metrics', async () => { + qb.getRawMany.mockResolvedValue([ + { + keyword: 'adult platform', + totalImpressions: '8000', + totalClicks: '400', + avgCtr: '0.05', + avgPosition: '4.2', + }, + { + keyword: 'creator marketplace', + totalImpressions: '5000', + totalClicks: '200', + avgCtr: '0.04', + avgPosition: '7.8', + }, + ]); + + const result = await service.getKeywords(query); + + expect(result.keywords).toHaveLength(2); + expect(result.total).toBe(2); + + const kw = result.keywords[0]; + expect(kw.keyword).toBe('adult platform'); + expect(kw.totalImpressions).toBe(8000); + expect(kw.totalClicks).toBe(400); + expect(kw.avgCtr).toBe(0.05); + expect(kw.avgPosition).toBe(4.2); + }); + + it('filters by path when provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getKeywords({ ...query, path: '/blog/test' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.path = :path', { path: '/blog/test' }); + }); + + it('does not filter by path when not provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getKeywords(query); + + // andWhere is called for date range but not for path + const pathCalls = qb.andWhere.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && call[0].includes('path'), + ); + expect(pathCalls).toHaveLength(0); + }); + + it('defaults limit to 50', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getKeywords({ domain: 'atlilith.com' }); + + expect(qb.limit).toHaveBeenCalledWith(50); + }); + + it('caps limit at 500', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getKeywords({ domain: 'atlilith.com', limit: '999' }); + + expect(qb.limit).toHaveBeenCalledWith(500); + }); + + it('returns empty keywords when no data', async () => { + qb.getRawMany.mockResolvedValue([]); + + const result = await service.getKeywords(query); + + expect(result.keywords).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + // ========================================================================== + // getPositionTrend + // ========================================================================== + + describe('getPositionTrend', () => { + const query = { + domain: 'atlilith.com', + path: '/blog/seo-guide', + startDate: '2026-01-01', + endDate: '2026-01-31', + }; + + it('returns daily position trend points', async () => { + qb.getRawMany.mockResolvedValue([ + { date: new Date('2026-01-15'), avgPosition: '3.5', impressions: '200', clicks: '10' }, + { date: new Date('2026-01-16'), avgPosition: '3.2', impressions: '250', clicks: '15' }, + ]); + + const result = await service.getPositionTrend(query); + + expect(result.points).toHaveLength(2); + expect(result.points[0].date).toBe('2026-01-15'); + expect(result.points[0].avgPosition).toBe(3.5); + expect(result.points[0].impressions).toBe(200); + expect(result.points[0].clicks).toBe(10); + expect(result.points[1].date).toBe('2026-01-16'); + }); + + it('filters by keyword when provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getPositionTrend({ ...query, keyword: 'seo tips' }); + + expect(qb.andWhere).toHaveBeenCalledWith('s.keyword = :keyword', { keyword: 'seo tips' }); + }); + + it('does not filter by keyword when not provided', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getPositionTrend(query); + + const keywordCalls = qb.andWhere.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && call[0].includes('keyword'), + ); + expect(keywordCalls).toHaveLength(0); + }); + + it('sets domain and path in query builder', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getPositionTrend(query); + + expect(qb.where).toHaveBeenCalledWith('s.domain = :domain', { domain: 'atlilith.com' }); + expect(qb.andWhere).toHaveBeenCalledWith('s.path = :path', { path: '/blog/seo-guide' }); + }); + + it('returns empty points when no data', async () => { + qb.getRawMany.mockResolvedValue([]); + + const result = await service.getPositionTrend(query); + + expect(result.points).toHaveLength(0); + }); + + it('orders by date ascending', async () => { + qb.getRawMany.mockResolvedValue([]); + + await service.getPositionTrend(query); + + expect(qb.orderBy).toHaveBeenCalledWith('"date"', 'ASC'); + }); + }); +}); diff --git a/features/platform-analytics/backend-api/test/seo-service.spec.ts b/features/platform-analytics/backend-api/test/seo-service.spec.ts new file mode 100644 index 000000000..776a7153a --- /dev/null +++ b/features/platform-analytics/backend-api/test/seo-service.spec.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { SeoService } from '@/modules/seo/seo.service'; +import { SeoApiClient } from '@/modules/seo/seo-api.client'; +import { AnalyticsApiClient } from '@/modules/analytics-gateway/analytics-api.client'; +import { + createOrganicChannel, + createDirectChannel, + createPageMetric, + createPageMetricBatch, + createCachedPage, + createCampaign, + createCampaignTarget, + createSessionMetrics, +} from './seo-fixtures'; + +vi.mock('@lilith/service-registry', () => ({ + buildDeploymentRegistry: () => ({ + services: new Map(), + }), +})); + +describe('SeoService', () => { + let service: SeoService; + let analyticsClient: { + getChannels: ReturnType; + getPages: ReturnType; + getSessionMetrics: ReturnType; + }; + let seoApiClient: { + getCachedPages: ReturnType; + getCampaigns: ReturnType; + getCampaignTargets: ReturnType; + }; + + beforeEach(async () => { + analyticsClient = { + getChannels: vi.fn(), + getPages: vi.fn(), + getSessionMetrics: vi.fn(), + }; + + seoApiClient = { + getCachedPages: vi.fn(), + getCampaigns: vi.fn(), + getCampaignTargets: vi.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SeoService, + { provide: AnalyticsApiClient, useValue: analyticsClient }, + { provide: SeoApiClient, useValue: seoApiClient }, + ], + }).compile(); + + service = module.get(SeoService); + }); + + // ========================================================================== + // getOverview + // ========================================================================== + + describe('getOverview', () => { + const query = { startDate: '2026-01-01', endDate: '2026-01-31' }; + + it('returns organic search KPIs when organic channel exists', async () => { + const organic = createOrganicChannel({ sessions: 9000, users: 6000, conversionRate: 4.5 }); + analyticsClient.getChannels.mockResolvedValue([organic, createDirectChannel()]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics({ bounceRate: 38.1, avgSessionDuration: 220 })); + + const result = await service.getOverview(query); + + expect(result.organicSessions).toBe(9000); + expect(result.organicUsers).toBe(6000); + expect(result.organicBounceRate).toBe(38.1); + expect(result.organicAvgDuration).toBe(220); + expect(result.organicConversionRate).toBe(4.5); + }); + + it('returns zeros when no organic channel found', async () => { + analyticsClient.getChannels.mockResolvedValue([createDirectChannel()]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + + const result = await service.getOverview(query); + + expect(result.organicSessions).toBe(0); + expect(result.organicUsers).toBe(0); + expect(result.organicConversionRate).toBe(0); + }); + + it('matches channel containing "search" (case insensitive)', async () => { + const searchChannel = createOrganicChannel({ channel: 'Google Search', sessions: 7500 }); + analyticsClient.getChannels.mockResolvedValue([searchChannel]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + + const result = await service.getOverview(query); + + expect(result.organicSessions).toBe(7500); + }); + + it('gracefully handles getChannels failure', async () => { + analyticsClient.getChannels.mockRejectedValue(new Error('Network error')); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + + const result = await service.getOverview(query); + + expect(result.organicSessions).toBe(0); + expect(result.organicUsers).toBe(0); + expect(result.organicBounceRate).toBe(createSessionMetrics().bounceRate); + }); + + it('gracefully handles getSessionMetrics failure', async () => { + analyticsClient.getChannels.mockResolvedValue([createOrganicChannel()]); + analyticsClient.getSessionMetrics.mockRejectedValue(new Error('Timeout')); + + const result = await service.getOverview(query); + + expect(result.organicBounceRate).toBe(0); + expect(result.organicAvgDuration).toBe(0); + }); + + it('passes query params to analytics client', async () => { + analyticsClient.getChannels.mockResolvedValue([]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + + await service.getOverview(query); + + expect(analyticsClient.getChannels).toHaveBeenCalledWith(query); + expect(analyticsClient.getSessionMetrics).toHaveBeenCalledWith(query); + }); + + it('always returns zero for change fields', async () => { + analyticsClient.getChannels.mockResolvedValue([createOrganicChannel()]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + + const result = await service.getOverview(query); + + expect(result.organicSessionsChange).toBe(0); + expect(result.organicUsersChange).toBe(0); + expect(result.organicBounceRateChange).toBe(0); + expect(result.organicAvgDurationChange).toBe(0); + }); + }); + + // ========================================================================== + // getLandingPages + // ========================================================================== + + describe('getLandingPages', () => { + const query = { startDate: '2026-01-01', endDate: '2026-01-31' }; + + it('returns landing pages with hasSeoContent flag', async () => { + const pages = [ + createPageMetric({ path: '/blog/seo-guide', views: 1200 }), + createPageMetric({ path: '/pricing', views: 800 }), + ]; + const cached = [createCachedPage({ path: '/blog/seo-guide' })]; + + analyticsClient.getPages.mockResolvedValue(pages); + seoApiClient.getCachedPages.mockResolvedValue(cached); + + const result = await service.getLandingPages(query); + + expect(result.pages).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.pages[0].path).toBe('/blog/seo-guide'); + expect(result.pages[0].hasSeoContent).toBe(true); + expect(result.pages[1].path).toBe('/pricing'); + expect(result.pages[1].hasSeoContent).toBe(false); + }); + + it('passes sort and limit from query', async () => { + analyticsClient.getPages.mockResolvedValue([]); + seoApiClient.getCachedPages.mockResolvedValue([]); + + await service.getLandingPages({ ...query, sort: 'bounce', limit: '10' }); + + expect(analyticsClient.getPages).toHaveBeenCalledWith( + expect.objectContaining({ sort: 'bounce', limit: '10' }), + ); + }); + + it('defaults sort to views and limit to 50', async () => { + analyticsClient.getPages.mockResolvedValue([]); + seoApiClient.getCachedPages.mockResolvedValue([]); + + await service.getLandingPages(query); + + expect(analyticsClient.getPages).toHaveBeenCalledWith( + expect.objectContaining({ sort: 'views', limit: '50' }), + ); + }); + + it('passes domain to getCachedPages', async () => { + analyticsClient.getPages.mockResolvedValue([]); + seoApiClient.getCachedPages.mockResolvedValue([]); + + await service.getLandingPages({ ...query, domain: 'atlilith.com' }); + + expect(seoApiClient.getCachedPages).toHaveBeenCalledWith('atlilith.com'); + }); + + it('maps all page fields correctly', async () => { + const page = createPageMetric({ + path: '/test', + views: 500, + uniqueViews: 400, + avgTimeOnPage: 120, + bounceRate: 45.0, + }); + analyticsClient.getPages.mockResolvedValue([page]); + seoApiClient.getCachedPages.mockResolvedValue([]); + + const result = await service.getLandingPages(query); + const mapped = result.pages[0]; + + expect(mapped.path).toBe('/test'); + expect(mapped.views).toBe(500); + expect(mapped.uniqueViews).toBe(400); + expect(mapped.avgTimeOnPage).toBe(120); + expect(mapped.bounceRate).toBe(45.0); + expect(mapped.hasSeoContent).toBe(false); + }); + + it('gracefully handles getPages failure', async () => { + analyticsClient.getPages.mockRejectedValue(new Error('API down')); + seoApiClient.getCachedPages.mockResolvedValue([createCachedPage()]); + + const result = await service.getLandingPages(query); + + expect(result.pages).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('gracefully handles getCachedPages failure', async () => { + analyticsClient.getPages.mockResolvedValue([createPageMetric()]); + seoApiClient.getCachedPages.mockRejectedValue(new Error('SEO service down')); + + const result = await service.getLandingPages(query); + + expect(result.pages).toHaveLength(1); + expect(result.pages[0].hasSeoContent).toBe(false); + }); + + it('returns empty when both APIs fail', async () => { + analyticsClient.getPages.mockRejectedValue(new Error('fail')); + seoApiClient.getCachedPages.mockRejectedValue(new Error('fail')); + + const result = await service.getLandingPages(query); + + expect(result.pages).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + // ========================================================================== + // getCampaigns + // ========================================================================== + + describe('getCampaigns', () => { + const query = { startDate: '2026-01-01', endDate: '2026-01-31' }; + + it('returns campaigns with aggregated traffic stats', async () => { + const campaign = createCampaign({ id: 'c1', name: 'Winter Push', targetCount: 2 }); + const targets = [ + createCampaignTarget({ path: '/blog/seo-guide', campaignId: 'c1' }), + createCampaignTarget({ path: '/pricing', campaignId: 'c1' }), + ]; + const pages = [ + createPageMetric({ path: '/blog/seo-guide', views: 1200, uniqueViews: 980, bounceRate: 40, avgTimeOnPage: 150 }), + createPageMetric({ path: '/pricing', views: 800, uniqueViews: 650, bounceRate: 50, avgTimeOnPage: 100 }), + ]; + + seoApiClient.getCampaigns.mockResolvedValue([campaign]); + seoApiClient.getCampaignTargets.mockResolvedValue(targets); + analyticsClient.getPages.mockResolvedValue(pages); + + const result = await service.getCampaigns(query); + + expect(result.campaigns).toHaveLength(1); + expect(result.total).toBe(1); + + const c = result.campaigns[0]; + expect(c.campaignId).toBe('c1'); + expect(c.campaignName).toBe('Winter Push'); + expect(c.totalViews).toBe(2000); + expect(c.totalUniqueViews).toBe(1630); + expect(c.avgBounceRate).toBe(45); // (40 + 50) / 2 + expect(c.avgTimeOnPage).toBe(125); // (150 + 100) / 2 + }); + + it('filters by campaignId when provided', async () => { + const campaigns = [ + createCampaign({ id: 'c1', name: 'Campaign 1' }), + createCampaign({ id: 'c2', name: 'Campaign 2' }), + ]; + + seoApiClient.getCampaigns.mockResolvedValue(campaigns); + seoApiClient.getCampaignTargets.mockResolvedValue([]); + analyticsClient.getPages.mockResolvedValue([]); + + const result = await service.getCampaigns({ ...query, campaignId: 'c2' }); + + expect(result.campaigns).toHaveLength(1); + expect(result.campaigns[0].campaignId).toBe('c2'); + expect(seoApiClient.getCampaignTargets).toHaveBeenCalledWith('c2'); + }); + + it('returns zero averages when no pages match targets', async () => { + const campaign = createCampaign({ id: 'c1' }); + const targets = [createCampaignTarget({ path: '/nonexistent', campaignId: 'c1' })]; + + seoApiClient.getCampaigns.mockResolvedValue([campaign]); + seoApiClient.getCampaignTargets.mockResolvedValue(targets); + analyticsClient.getPages.mockResolvedValue([createPageMetric({ path: '/other' })]); + + const result = await service.getCampaigns(query); + const c = result.campaigns[0]; + + expect(c.totalViews).toBe(0); + expect(c.totalUniqueViews).toBe(0); + expect(c.avgBounceRate).toBe(0); + expect(c.avgTimeOnPage).toBe(0); + }); + + it('gracefully handles getCampaigns failure', async () => { + seoApiClient.getCampaigns.mockRejectedValue(new Error('SEO API down')); + analyticsClient.getPages.mockResolvedValue([]); + + const result = await service.getCampaigns(query); + + expect(result.campaigns).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('gracefully handles getCampaignTargets failure for a campaign', async () => { + seoApiClient.getCampaigns.mockResolvedValue([createCampaign({ id: 'c1' })]); + seoApiClient.getCampaignTargets.mockRejectedValue(new Error('Targets unavailable')); + analyticsClient.getPages.mockResolvedValue([createPageMetric()]); + + const result = await service.getCampaigns(query); + + expect(result.campaigns).toHaveLength(1); + expect(result.campaigns[0].totalViews).toBe(0); + }); + + it('gracefully handles getPages failure', async () => { + seoApiClient.getCampaigns.mockResolvedValue([createCampaign()]); + seoApiClient.getCampaignTargets.mockResolvedValue([createCampaignTarget()]); + analyticsClient.getPages.mockRejectedValue(new Error('Analytics down')); + + const result = await service.getCampaigns(query); + + expect(result.campaigns).toHaveLength(1); + expect(result.campaigns[0].totalViews).toBe(0); + }); + + it('fetches pages with limit 500 and sort by views', async () => { + seoApiClient.getCampaigns.mockResolvedValue([]); + analyticsClient.getPages.mockResolvedValue([]); + + await service.getCampaigns(query); + + expect(analyticsClient.getPages).toHaveBeenCalledWith( + expect.objectContaining({ sort: 'views', limit: '500' }), + ); + }); + + it('handles multiple campaigns in parallel', async () => { + const campaigns = [ + createCampaign({ id: 'c1', name: 'Campaign A' }), + createCampaign({ id: 'c2', name: 'Campaign B' }), + ]; + const targetsC1 = [createCampaignTarget({ path: '/page-a', campaignId: 'c1' })]; + const targetsC2 = [createCampaignTarget({ path: '/page-b', campaignId: 'c2' })]; + const pages = [ + createPageMetric({ path: '/page-a', views: 500 }), + createPageMetric({ path: '/page-b', views: 300 }), + ]; + + seoApiClient.getCampaigns.mockResolvedValue(campaigns); + seoApiClient.getCampaignTargets + .mockResolvedValueOnce(targetsC1) + .mockResolvedValueOnce(targetsC2); + analyticsClient.getPages.mockResolvedValue(pages); + + const result = await service.getCampaigns(query); + + expect(result.campaigns).toHaveLength(2); + expect(result.campaigns[0].totalViews).toBe(500); + expect(result.campaigns[1].totalViews).toBe(300); + }); + + it('preserves campaign metadata in response', async () => { + const campaign = createCampaign({ + id: 'c1', + name: 'Spring Launch', + status: 'draft', + targetCount: 10, + }); + + seoApiClient.getCampaigns.mockResolvedValue([campaign]); + seoApiClient.getCampaignTargets.mockResolvedValue([]); + analyticsClient.getPages.mockResolvedValue([]); + + const result = await service.getCampaigns(query); + const c = result.campaigns[0]; + + expect(c.campaignName).toBe('Spring Launch'); + expect(c.status).toBe('draft'); + expect(c.targetCount).toBe(10); + }); + }); +}); diff --git a/features/platform-analytics/backend-api/test/seo.e2e-spec.ts b/features/platform-analytics/backend-api/test/seo.e2e-spec.ts new file mode 100644 index 000000000..69a414c3b --- /dev/null +++ b/features/platform-analytics/backend-api/test/seo.e2e-spec.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import { ConfigModule } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { SeoController } from '@/modules/seo/seo.controller'; +import { SeoService } from '@/modules/seo/seo.service'; +import { SeoRankingsService } from '@/modules/seo/seo-rankings.service'; +import { SeoApiClient } from '@/modules/seo/seo-api.client'; +import { AnalyticsApiClient } from '@/modules/analytics-gateway/analytics-api.client'; +import { SeoRankingSnapshot } from '@/entities'; +import { + createMockRepository, + createMockQueryBuilder, + type MockRepository, + type MockQueryBuilder, +} from './mocks'; +import { + createOrganicChannel, + createDirectChannel, + createPageMetric, + createCachedPage, + createCampaignTarget, + createSessionMetrics, +} from './seo-fixtures'; + +vi.mock('@lilith/service-registry', () => ({ + buildDeploymentRegistry: () => ({ + services: new Map(), + }), +})); + +describe('SEO API (E2E)', () => { + let app: INestApplication; + let analyticsClient: { + getChannels: ReturnType; + getPages: ReturnType; + getSessionMetrics: ReturnType; + }; + let snapshotRepo: MockRepository; + let snapshotQb: MockQueryBuilder; + let fetchSpy: ReturnType; + + beforeAll(async () => { + analyticsClient = { + getChannels: vi.fn(), + getPages: vi.fn(), + getSessionMetrics: vi.fn(), + }; + + snapshotRepo = createMockRepository(); + snapshotQb = createMockQueryBuilder(); + snapshotRepo.createQueryBuilder.mockReturnValue(snapshotQb); + + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + load: [() => ({ + REDIS_URL: '', + SEO_API_URL: 'http://seo-test:3014', + ANALYTICS_API_URL: 'http://analytics-test:4003', + })], + }), + ], + controllers: [SeoController], + providers: [ + SeoService, + SeoRankingsService, + SeoApiClient, + { provide: AnalyticsApiClient, useValue: analyticsClient }, + { provide: getRepositoryToken(SeoRankingSnapshot), useValue: snapshotRepo }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Re-create fresh query builder and bind to repo + snapshotQb = createMockQueryBuilder(); + // Add setParameter (not in shared mock but used by SeoRankingsService) + (snapshotQb as Record>).setParameter = vi.fn().mockReturnValue(snapshotQb); + snapshotRepo.createQueryBuilder.mockReturnValue(snapshotQb); + + // Default snapshot rankings response + snapshotQb.getRawMany.mockResolvedValue([ + { + path: '/blog/seo-guide', + avgPosition: '3.45', + totalImpressions: '5000', + totalClicks: '250', + avgCtr: '0.05', + firstHalfImpressions: '2000', + secondHalfImpressions: '3000', + }, + ]); + + // Default analytics mock responses + analyticsClient.getChannels.mockResolvedValue([createOrganicChannel(), createDirectChannel()]); + analyticsClient.getSessionMetrics.mockResolvedValue(createSessionMetrics()); + analyticsClient.getPages.mockResolvedValue([ + createPageMetric({ path: '/blog/seo-guide', views: 1200 }), + createPageMetric({ path: '/pricing', views: 800 }), + ]); + + // Default SEO API responses for SeoApiClient's fetch calls + fetchSpy.mockImplementation(async (url: string) => { + const parsedUrl = new URL(url); + const path = parsedUrl.pathname; + + if (path === '/campaigns') { + return mockJsonResponse([ + { + id: 'c1', + name: 'Winter Push', + status: 'active', + stats: { total: 25, generated: 20, published: 18 }, + }, + ]); + } + + if (path.match(/^\/campaigns\/[^/]+\/targets$/)) { + return mockJsonResponse([ + createCampaignTarget({ path: '/blog/seo-guide' }), + ]); + } + + if (path === '/content') { + return mockJsonResponse([ + createCachedPage({ path: '/blog/seo-guide' }), + ]); + } + + return mockJsonResponse([], 404); + }); + }); + + function mockJsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers({ 'Content-Type': 'application/json' }), + } as Response; + } + + // ========================================================================== + // GET /insights/seo/overview + // ========================================================================== + + describe('GET /insights/seo/overview', () => { + it('returns 200 with organic search KPIs', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/overview') + .expect(200); + + expect(response.body).toHaveProperty('organicSessions'); + expect(response.body).toHaveProperty('organicUsers'); + expect(response.body).toHaveProperty('organicBounceRate'); + expect(response.body).toHaveProperty('organicAvgDuration'); + expect(response.body).toHaveProperty('organicConversionRate'); + expect(response.body).toHaveProperty('organicSessionsChange'); + expect(response.body).toHaveProperty('organicUsersChange'); + expect(response.body).toHaveProperty('organicBounceRateChange'); + expect(response.body).toHaveProperty('organicAvgDurationChange'); + }); + + it('returns numeric values from organic channel', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/overview') + .expect(200); + + expect(typeof response.body.organicSessions).toBe('number'); + expect(typeof response.body.organicUsers).toBe('number'); + expect(typeof response.body.organicBounceRate).toBe('number'); + expect(response.body.organicSessions).toBe(8450); + expect(response.body.organicUsers).toBe(5632); + }); + + it('accepts date range query parameters', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/overview?startDate=2026-01-01&endDate=2026-01-31') + .expect(200); + + expect(analyticsClient.getChannels).toHaveBeenCalledWith( + expect.objectContaining({ startDate: '2026-01-01', endDate: '2026-01-31' }), + ); + }); + + it('returns 200 with zeros when analytics fail', async () => { + analyticsClient.getChannels.mockRejectedValue(new Error('down')); + analyticsClient.getSessionMetrics.mockRejectedValue(new Error('down')); + + const response = await request(app.getHttpServer()) + .get('/insights/seo/overview') + .expect(200); + + expect(response.body.organicSessions).toBe(0); + expect(response.body.organicUsers).toBe(0); + }); + }); + + // ========================================================================== + // GET /insights/seo/landing-pages + // ========================================================================== + + describe('GET /insights/seo/landing-pages', () => { + it('returns 200 with pages array and total', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/landing-pages') + .expect(200); + + expect(response.body).toHaveProperty('pages'); + expect(response.body).toHaveProperty('total'); + expect(Array.isArray(response.body.pages)).toBe(true); + expect(response.body.total).toBeGreaterThan(0); + }); + + it('returns page objects with correct shape', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/landing-pages') + .expect(200); + + const page = response.body.pages[0]; + expect(page).toHaveProperty('path'); + expect(page).toHaveProperty('views'); + expect(page).toHaveProperty('uniqueViews'); + expect(page).toHaveProperty('avgTimeOnPage'); + expect(page).toHaveProperty('bounceRate'); + expect(page).toHaveProperty('hasSeoContent'); + }); + + it('cross-references SEO content cache for hasSeoContent', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/landing-pages') + .expect(200); + + const seoPage = response.body.pages.find((p: { path: string }) => p.path === '/blog/seo-guide'); + const nonSeoPage = response.body.pages.find((p: { path: string }) => p.path === '/pricing'); + + expect(seoPage?.hasSeoContent).toBe(true); + expect(nonSeoPage?.hasSeoContent).toBe(false); + }); + + it('accepts sort, limit, and domain query parameters', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/landing-pages?sort=bounce&limit=10&domain=atlilith.com') + .expect(200); + }); + + it('returns empty pages when analytics fail', async () => { + analyticsClient.getPages.mockRejectedValue(new Error('down')); + + const response = await request(app.getHttpServer()) + .get('/insights/seo/landing-pages') + .expect(200); + + expect(response.body.pages).toHaveLength(0); + expect(response.body.total).toBe(0); + }); + }); + + // ========================================================================== + // GET /insights/seo/campaigns + // ========================================================================== + + describe('GET /insights/seo/campaigns', () => { + it('returns 200 with campaigns array and total', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/campaigns') + .expect(200); + + expect(response.body).toHaveProperty('campaigns'); + expect(response.body).toHaveProperty('total'); + expect(Array.isArray(response.body.campaigns)).toBe(true); + }); + + it('returns campaign objects with traffic stats', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/campaigns') + .expect(200); + + if (response.body.campaigns.length > 0) { + const campaign = response.body.campaigns[0]; + expect(campaign).toHaveProperty('campaignId'); + expect(campaign).toHaveProperty('campaignName'); + expect(campaign).toHaveProperty('status'); + expect(campaign).toHaveProperty('targetCount'); + expect(campaign).toHaveProperty('totalViews'); + expect(campaign).toHaveProperty('totalUniqueViews'); + expect(campaign).toHaveProperty('avgBounceRate'); + expect(campaign).toHaveProperty('avgTimeOnPage'); + } + }); + + it('aggregates traffic from matched target pages', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/campaigns') + .expect(200); + + const campaign = response.body.campaigns[0]; + expect(campaign.totalViews).toBe(1200); + expect(typeof campaign.avgBounceRate).toBe('number'); + }); + + it('accepts campaignId filter', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/campaigns?campaignId=c1') + .expect(200); + }); + + it('accepts date range query parameters', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/campaigns?startDate=2026-01-01&endDate=2026-01-31') + .expect(200); + }); + + it('returns empty campaigns when SEO API fails', async () => { + fetchSpy.mockRejectedValue(new Error('SEO service down')); + + const response = await request(app.getHttpServer()) + .get('/insights/seo/campaigns') + .expect(200); + + expect(response.body.campaigns).toHaveLength(0); + expect(response.body.total).toBe(0); + }); + }); + + // ========================================================================== + // GET /insights/seo/rankings + // ========================================================================== + + describe('GET /insights/seo/rankings', () => { + it('returns 200 with rankings array and total', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/rankings?domain=atlilith.com') + .expect(200); + + expect(response.body).toHaveProperty('rankings'); + expect(response.body).toHaveProperty('total'); + expect(Array.isArray(response.body.rankings)).toBe(true); + }); + + it('returns ranking objects with correct shape', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/rankings?domain=atlilith.com') + .expect(200); + + if (response.body.rankings.length > 0) { + const ranking = response.body.rankings[0]; + expect(ranking).toHaveProperty('path'); + expect(ranking).toHaveProperty('avgPosition'); + expect(ranking).toHaveProperty('totalImpressions'); + expect(ranking).toHaveProperty('totalClicks'); + expect(ranking).toHaveProperty('avgCtr'); + expect(ranking).toHaveProperty('impressionsTrend'); + } + }); + + it('returns numeric values from snapshot data', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/rankings?domain=atlilith.com') + .expect(200); + + const ranking = response.body.rankings[0]; + expect(typeof ranking.avgPosition).toBe('number'); + expect(typeof ranking.totalImpressions).toBe('number'); + expect(ranking.avgPosition).toBe(3.45); + expect(ranking.totalImpressions).toBe(5000); + }); + + it('accepts date range and limit query parameters', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/rankings?domain=atlilith.com&startDate=2026-01-01&endDate=2026-01-31&limit=10') + .expect(200); + }); + + it('requires domain parameter', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/rankings') + .expect(400); + }); + }); + + // ========================================================================== + // GET /insights/seo/keywords + // ========================================================================== + + describe('GET /insights/seo/keywords', () => { + beforeEach(() => { + snapshotQb.getRawMany.mockResolvedValue([ + { + keyword: 'adult platform', + totalImpressions: '8000', + totalClicks: '400', + avgCtr: '0.05', + avgPosition: '4.2', + }, + ]); + }); + + it('returns 200 with keywords array and total', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/keywords?domain=atlilith.com') + .expect(200); + + expect(response.body).toHaveProperty('keywords'); + expect(response.body).toHaveProperty('total'); + expect(Array.isArray(response.body.keywords)).toBe(true); + }); + + it('returns keyword objects with correct shape', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/keywords?domain=atlilith.com') + .expect(200); + + if (response.body.keywords.length > 0) { + const kw = response.body.keywords[0]; + expect(kw).toHaveProperty('keyword'); + expect(kw).toHaveProperty('totalImpressions'); + expect(kw).toHaveProperty('totalClicks'); + expect(kw).toHaveProperty('avgCtr'); + expect(kw).toHaveProperty('avgPosition'); + } + }); + + it('accepts path filter parameter', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/keywords?domain=atlilith.com&path=/blog/test') + .expect(200); + }); + + it('requires domain parameter', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/keywords') + .expect(400); + }); + }); + + // ========================================================================== + // GET /insights/seo/position-trend + // ========================================================================== + + describe('GET /insights/seo/position-trend', () => { + beforeEach(() => { + snapshotQb.getRawMany.mockResolvedValue([ + { date: new Date('2026-01-15'), avgPosition: '3.5', impressions: '200', clicks: '10' }, + { date: new Date('2026-01-16'), avgPosition: '3.2', impressions: '250', clicks: '15' }, + ]); + }); + + it('returns 200 with points array', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/position-trend?domain=atlilith.com&path=/blog/seo-guide') + .expect(200); + + expect(response.body).toHaveProperty('points'); + expect(Array.isArray(response.body.points)).toBe(true); + }); + + it('returns trend point objects with correct shape', async () => { + const response = await request(app.getHttpServer()) + .get('/insights/seo/position-trend?domain=atlilith.com&path=/blog/seo-guide') + .expect(200); + + if (response.body.points.length > 0) { + const point = response.body.points[0]; + expect(point).toHaveProperty('date'); + expect(point).toHaveProperty('avgPosition'); + expect(point).toHaveProperty('impressions'); + expect(point).toHaveProperty('clicks'); + } + }); + + it('accepts keyword filter parameter', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/position-trend?domain=atlilith.com&path=/blog/test&keyword=seo') + .expect(200); + }); + + it('requires domain and path parameters', async () => { + await request(app.getHttpServer()) + .get('/insights/seo/position-trend') + .expect(400); + + await request(app.getHttpServer()) + .get('/insights/seo/position-trend?domain=atlilith.com') + .expect(400); + }); + }); +});