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:
parent
b383a03cbc
commit
11bf3e771a
5 changed files with 1611 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
131
features/platform-analytics/backend-api/test/seo-fixtures.ts
Normal file
131
features/platform-analytics/backend-api/test/seo-fixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
416
features/platform-analytics/backend-api/test/seo-service.spec.ts
Normal file
416
features/platform-analytics/backend-api/test/seo-service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
510
features/platform-analytics/backend-api/test/seo.e2e-spec.ts
Normal file
510
features/platform-analytics/backend-api/test/seo.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue