510 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|