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

244 lines
7.6 KiB
TypeScript

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