mirror of
https://github.com/BradNut/personal-website-sveltekit
synced 2025-09-08 23:20:18 +00:00
Mocking bandcamp call and adding articles test with mocks.
This commit is contained in:
parent
2714d05917
commit
de0155e0db
2 changed files with 185 additions and 10 deletions
145
src/lib/api.test.ts
Normal file
145
src/lib/api.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Hoisted mocks to satisfy Vitest's module mock hoisting
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
return {
|
||||||
|
redisMock: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
ttl: vi.fn(),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock env constants used by fetchArticlesApi
|
||||||
|
vi.mock('$env/static/private', () => ({
|
||||||
|
PAGE_SIZE: '10',
|
||||||
|
USE_REDIS_CACHE: 'true', // enable cache to test both hit/miss paths with stubs
|
||||||
|
WALLABAG_CLIENT_ID: 'client-id',
|
||||||
|
WALLABAG_CLIENT_SECRET: 'client-secret',
|
||||||
|
WALLABAG_PASSWORD: 'password',
|
||||||
|
WALLABAG_URL: 'https://wallabag.example',
|
||||||
|
WALLABAG_USERNAME: 'username',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock redis client so no real connection is used
|
||||||
|
vi.mock('$lib/server/redis', () => ({
|
||||||
|
redis: hoisted.redisMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to mock global fetch responses
|
||||||
|
function makeJsonResponse<T>(data: T, headers: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
|
||||||
|
json: async () => data,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
import { fetchArticlesApi } from './api';
|
||||||
|
|
||||||
|
describe('fetchArticlesApi (unit, mocked)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches and maps articles on cache miss, then stores in redis', async () => {
|
||||||
|
// Cache miss setup
|
||||||
|
hoisted.redisMock.get.mockResolvedValueOnce(null);
|
||||||
|
hoisted.redisMock.ttl.mockResolvedValueOnce(0);
|
||||||
|
|
||||||
|
// Mock token fetch
|
||||||
|
const token = { access_token: 'access-token' } as const;
|
||||||
|
// Mock entries fetch
|
||||||
|
const wallabagResponse = {
|
||||||
|
_embedded: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Great Post',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
domain_name: 'www.example.com',
|
||||||
|
hashed_url: 'hash123',
|
||||||
|
reading_time: 7,
|
||||||
|
preview_picture: 'https://example.com/img.jpg',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-02T00:00:00Z',
|
||||||
|
archived_at: null,
|
||||||
|
tags: [{ slug: 'programming' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
pages: 5,
|
||||||
|
total: 100,
|
||||||
|
limit: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (input: unknown) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith('/oauth/v2/token')) {
|
||||||
|
return makeJsonResponse(token);
|
||||||
|
}
|
||||||
|
if (url.startsWith('https://wallabag.example/api/entries.json')) {
|
||||||
|
return makeJsonResponse(wallabagResponse, { 'cache-control': 'max-age=60' });
|
||||||
|
}
|
||||||
|
throw new Error('Unexpected fetch to ' + url);
|
||||||
|
});
|
||||||
|
// @ts-expect-error assign to global
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const result = await fetchArticlesApi('GET', 'entries', { page: '1', limit: '10' });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.currentPage).toBe(1);
|
||||||
|
expect(result.totalPages).toBe(5);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.totalArticles).toBe(100);
|
||||||
|
expect(result.cacheControl).toBe('max-age=60');
|
||||||
|
|
||||||
|
expect(result.articles.length).toBe(1);
|
||||||
|
const article = result.articles[0];
|
||||||
|
expect(article.title).toBe('Great Post');
|
||||||
|
expect(article.url).toBeInstanceOf(URL);
|
||||||
|
expect(article.url.hostname).toBe('example.com');
|
||||||
|
expect(article.domain_name).toBe('example.com');
|
||||||
|
|
||||||
|
// Stored in Redis with EX for 12 hours
|
||||||
|
expect(hoisted.redisMock.set).toHaveBeenCalled();
|
||||||
|
const setArgs = (hoisted.redisMock.set as unknown as { mock: { calls: unknown[][] } }).mock
|
||||||
|
.calls[0] as [string, string, 'EX', number];
|
||||||
|
expect(setArgs[0]).toContain('perPage=10');
|
||||||
|
expect(setArgs[0]).toContain('page=1');
|
||||||
|
expect(setArgs[2]).toBe('EX');
|
||||||
|
expect(setArgs[3]).toBe(43200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached response and cacheControl when redis has value (cache hit)', async () => {
|
||||||
|
const cached = {
|
||||||
|
articles: [],
|
||||||
|
currentPage: 2,
|
||||||
|
totalPages: 3,
|
||||||
|
limit: 10,
|
||||||
|
totalArticles: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
hoisted.redisMock.get.mockResolvedValueOnce(JSON.stringify(cached));
|
||||||
|
hoisted.redisMock.ttl.mockResolvedValueOnce(321);
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
// @ts-expect-error assign to global
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const result = await fetchArticlesApi('GET', 'entries', { page: '2', limit: '10' });
|
||||||
|
|
||||||
|
// No network calls on cache hit
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.currentPage).toBe(2);
|
||||||
|
expect(result.totalPages).toBe(3);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.totalArticles).toBe(20);
|
||||||
|
expect(result.cacheControl).toBe('max-age=321');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,17 +1,47 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock env to avoid relying on GitHub secrets and to disable Redis usage
|
||||||
|
vi.mock('$env/static/private', () => ({
|
||||||
|
BANDCAMP_USERNAME: 'testuser',
|
||||||
|
USE_REDIS_CACHE: 'false',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub Redis client so no real connection is attempted
|
||||||
|
vi.mock('$lib/server/redis', () => ({
|
||||||
|
redis: {
|
||||||
|
get: vi.fn(async () => null),
|
||||||
|
set: vi.fn(async () => 'OK'),
|
||||||
|
ttl: vi.fn(async () => 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock scrape-it to avoid real network calls to Bandcamp
|
||||||
|
vi.mock('scrape-it', () => ({
|
||||||
|
default: vi.fn(async () => ({
|
||||||
|
data: {
|
||||||
|
collectionItems: [
|
||||||
|
{
|
||||||
|
url: 'https://bandcamp.com/album/123',
|
||||||
|
artwork: 'https://img.bandcamp.com/art.jpg',
|
||||||
|
title: 'Test Album',
|
||||||
|
artist: 'Test Artist',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
import { fetchBandcampAlbums } from './fetchBandcampAlbums';
|
import { fetchBandcampAlbums } from './fetchBandcampAlbums';
|
||||||
|
|
||||||
describe('test fetchBandcampAlbums', () => {
|
describe('fetchBandcampAlbums (mocked)', () => {
|
||||||
it('fetches bandcamp albums', async () => {
|
it('returns albums from mocked scrape-it', async () => {
|
||||||
const albums = await fetchBandcampAlbums();
|
const albums = await fetchBandcampAlbums();
|
||||||
expect(albums).not.toBeNull();
|
|
||||||
expect(albums).toBeTruthy();
|
expect(albums).toBeTruthy();
|
||||||
expect(albums?.length).toBeGreaterThan(0);
|
expect(albums?.length).toBeGreaterThan(0);
|
||||||
for (const album of albums) {
|
const [album] = albums;
|
||||||
expect(album?.artist).toHaveLength;
|
expect(album.artist).toBe('Test Artist');
|
||||||
expect(album?.artwork).toHaveLength;
|
expect(album.title).toBe('Test Album');
|
||||||
expect(album?.title).toHaveLength;
|
expect(album.url).toBe('https://bandcamp.com/album/123');
|
||||||
expect(album?.url).toHaveLength;
|
expect(album.artwork).toBe('https://img.bandcamp.com/art.jpg');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue