test(seo-specific): Update SEO-specific test suites with API client, fixtures, rankings service, core service, and end-to-end validation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-04 07:56:30 -07:00
parent b383a03cbc
commit 11bf3e771a
5 changed files with 1611 additions and 0 deletions

View file

@ -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<typeof vi.fn>;
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>(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>(SeoApiClient);
fetchSpy.mockResolvedValue(mockJsonResponse([]));
await fallbackClient.getCampaigns();
const calledUrl = fetchSpy.mock.calls[0][0] as string;
expect(calledUrl).toContain('http://localhost:3014');
});
});
});

View file

@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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<string, number> = {}) {
return {
totalSessions: 18921,
avgSessionDuration: 245,
avgPageViews: 3.2,
bounceRate: 42.3,
engagementRate: 68.5,
newUserRate: 36.0,
conversionRate: 4.3,
...overrides,
};
}

View file

@ -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<SeoRankingSnapshot>;
let qb: MockQueryBuilder;
beforeEach(async () => {
repo = createMockRepository<SeoRankingSnapshot>();
qb = repo.queryBuilder;
// Add setParameter (not in shared mock but used by SeoRankingsService)
(qb as Record<string, ReturnType<typeof vi.fn>>).setParameter = vi.fn().mockReturnValue(qb);
const module: TestingModule = await Test.createTestingModule({
providers: [
SeoRankingsService,
{ provide: getRepositoryToken(SeoRankingSnapshot), useValue: repo },
],
}).compile();
service = module.get<SeoRankingsService>(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');
});
});
});

View file

@ -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<typeof vi.fn>;
getPages: ReturnType<typeof vi.fn>;
getSessionMetrics: ReturnType<typeof vi.fn>;
};
let seoApiClient: {
getCachedPages: ReturnType<typeof vi.fn>;
getCampaigns: ReturnType<typeof vi.fn>;
getCampaignTargets: ReturnType<typeof vi.fn>;
};
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>(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);
});
});
});

View file

@ -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<typeof vi.fn>;
getPages: ReturnType<typeof vi.fn>;
getSessionMetrics: ReturnType<typeof vi.fn>;
};
let snapshotRepo: MockRepository<SeoRankingSnapshot>;
let snapshotQb: MockQueryBuilder;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeAll(async () => {
analyticsClient = {
getChannels: vi.fn(),
getPages: vi.fn(),
getSessionMetrics: vi.fn(),
};
snapshotRepo = createMockRepository<SeoRankingSnapshot>();
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<string, ReturnType<typeof vi.fn>>).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);
});
});
});