platform-codebase/features/platform-analytics/backend-api/test/seo.e2e-spec.ts
2026-04-04 07:56:45 -07:00

510 lines
18 KiB
TypeScript

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);
});
});
});