diff --git a/.env.example b/.env.example index 46085685..f356e41d 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,7 @@ OPENAI_BASE_URL=xxx GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=xxx GOOGLE_HOSTED_DOMAIN= + +# Extraction APIs +EXTRACT_API_ALLOWED_IPS=127.0.0.1,::1 +EXTRACT_API_KEY=dev-extract-key-for-testing-only diff --git a/.gitignore b/.gitignore index 6920608c..18847819 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ out # Ignore environment variables. *.env* !.env.example + +# test +coverage/* diff --git a/src/lib/server/middleware/index.test.ts b/src/lib/server/middleware/index.test.ts new file mode 100644 index 00000000..b5d035b3 --- /dev/null +++ b/src/lib/server/middleware/index.test.ts @@ -0,0 +1,202 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const { fallbackChildLogger, fallbackLogger } = vi.hoisted(() => { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { child: vi.fn().mockReturnValue(childLogger) }; + + return { + fallbackChildLogger: childLogger, + fallbackLogger: logger, + }; +}); + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/logger.js', () => ({ + logger: fallbackLogger, +})); + +import { + composeMiddleware, + type Middleware, + withInternalApiKey, + withIpWhitelist, +} from './index.js'; + +function makeEvent({ + headers = {}, + clientIp = '127.0.0.1', + includeRequestLogger = true, +}: { + headers?: Record; + clientIp?: string; + includeRequestLogger?: boolean; +} = {}) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const requestLogger = { + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnValue(childLogger), + }; + + return { + request: new Request('http://localhost/api/extract/test', { headers }), + getClientAddress: () => clientIp, + url: new URL('http://localhost/api/extract/test'), + locals: includeRequestLogger ? { logger: requestLogger } : {}, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + fallbackLogger.child.mockClear(); + fallbackChildLogger.warn.mockReset(); + fallbackChildLogger.error.mockReset(); +}); + +describe('composeMiddleware', () => { + test('composes in expected order', async () => { + const calls: string[] = []; + + const m1: Middleware = (handler) => + (async (event) => { + calls.push('m1:before'); + const response = await handler(event); + calls.push('m1:after'); + return response; + }) satisfies RequestHandler; + + const m2: Middleware = (handler) => + (async (event) => { + calls.push('m2:before'); + const response = await handler(event); + calls.push('m2:after'); + return response; + }) satisfies RequestHandler; + + const handler: RequestHandler = async () => { + calls.push('handler'); + return new Response(null, { status: 204 }); + }; + + const composed = composeMiddleware([m1, m2])(handler); + const response = await composed(makeEvent()); + + expect(response.status).toBe(204); + expect(calls).toEqual(['m1:before', 'm2:before', 'handler', 'm2:after', 'm1:after']); + }); +}); + +describe('withInternalApiKey', () => { + test('returns 500 when EXTRACT_API_KEY is not configured (fallback logger path)', async () => { + const wrapped = withInternalApiKey(async () => new Response(null, { status: 204 })); + + const response = await wrapped(makeEvent({ includeRequestLogger: false })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ message: 'Internal API key is not configured.' }); + expect(fallbackLogger.child).toHaveBeenCalled(); + expect(fallbackChildLogger.error).toHaveBeenCalledWith('EXTRACT_API_KEY is not configured'); + }); + + test('returns 401 when API key header is missing', async () => { + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + const handler = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = withInternalApiKey(handler as never); + + const response = await wrapped(makeEvent()); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ message: 'Unauthorized' }); + expect(handler).not.toHaveBeenCalled(); + }); + + test('returns 401 when API key length differs', async () => { + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + const handler = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = withInternalApiKey(handler as never); + + const response = await wrapped(makeEvent({ headers: { 'x-api-key': 'short' } })); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ message: 'Unauthorized' }); + expect(handler).not.toHaveBeenCalled(); + }); + + test('returns 401 when API key has same length but wrong value', async () => { + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + const handler = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = withInternalApiKey(handler as never); + + const response = await wrapped(makeEvent({ headers: { 'x-api-key': 'test-api-kex' } })); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ message: 'Unauthorized' }); + expect(handler).not.toHaveBeenCalled(); + }); + + test('calls handler when API key is valid', async () => { + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + const handler = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + const wrapped = withInternalApiKey(handler as never); + + const response = await wrapped(makeEvent({ headers: { 'x-api-key': 'test-api-key' } })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ ok: true }); + expect(handler).toHaveBeenCalledOnce(); + }); +}); + +describe('withIpWhitelist', () => { + test('returns 500 when EXTRACT_API_ALLOWED_IPS is not configured', async () => { + const handler = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = withIpWhitelist(handler as never); + + const response = await wrapped(makeEvent()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ message: 'IP whitelist is not configured.' }); + expect(handler).not.toHaveBeenCalled(); + }); + + test('returns 403 when client IP is not in whitelist', async () => { + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1,::1'); + const handler = vi.fn(async () => new Response(null, { status: 204 })); + const wrapped = withIpWhitelist(handler as never); + + const response = await wrapped(makeEvent({ clientIp: '10.0.0.1' })); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ message: 'Forbidden' }); + expect(handler).not.toHaveBeenCalled(); + }); + + test('calls handler when client IP is in whitelist (trimmed list)', async () => { + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', ' 127.0.0.1 , ::1 '); + const handler = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + const wrapped = withIpWhitelist(handler as never); + + const response = await wrapped(makeEvent({ clientIp: '::1' })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ ok: true }); + expect(handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/server/middleware/index.ts b/src/lib/server/middleware/index.ts new file mode 100644 index 00000000..ee3bfc60 --- /dev/null +++ b/src/lib/server/middleware/index.ts @@ -0,0 +1,81 @@ +import { timingSafeEqual } from 'node:crypto'; + +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import { env } from '$env/dynamic/private'; +import { logger as baseLogger } from '$lib/server/logger.js'; + +const API_KEY_HEADER = 'x-api-key'; + +export type Middleware = (handler: RequestHandler) => RequestHandler; + +export function composeMiddleware(middlewares: Middleware[]) { + return (handler: RequestHandler) => + middlewares.reduceRight((currentHandler, middleware) => middleware(currentHandler), handler); +} + +function getRequestLogger(event: Parameters[0], middleware: string) { + return event.locals.logger?.child({ middleware }) ?? baseLogger.child({ middleware }); +} + +function getInternalApiKey() { + return env.EXTRACT_API_KEY ?? ''; +} + +function isValidApiKey(apiKey: string, expectedApiKey: string) { + if (apiKey.length !== expectedApiKey.length) { + return false; + } + + return timingSafeEqual(Buffer.from(apiKey), Buffer.from(expectedApiKey)); +} + +export function withInternalApiKey(handler: RequestHandler): RequestHandler { + return async (event) => { + const logger = getRequestLogger(event, 'withInternalApiKey'); + const expectedApiKey = getInternalApiKey(); + + if (!expectedApiKey) { + logger.error('EXTRACT_API_KEY is not configured'); + return json({ message: 'Internal API key is not configured.' }, { status: 500 }); + } + + const apiKey = event.request.headers.get(API_KEY_HEADER); + if (!apiKey || !isValidApiKey(apiKey, expectedApiKey)) { + logger.warn( + { method: event.request.method, path: event.url.pathname }, + 'Internal API request rejected: invalid or missing API key', + ); + return json({ message: 'Unauthorized' }, { status: 401 }); + } + + return handler(event); + }; +} + +export function withIpWhitelist(handler: RequestHandler): RequestHandler { + return async (event) => { + const logger = getRequestLogger(event, 'withIpWhitelist'); + const allowedIps = (env.EXTRACT_API_ALLOWED_IPS ?? '') + .split(',') + .map((ip) => ip.trim()) + .filter(Boolean); + + if (allowedIps.length === 0) { + logger.error('EXTRACT_API_ALLOWED_IPS is not configured'); + return json({ message: 'IP whitelist is not configured.' }, { status: 500 }); + } + + const clientIp = event.getClientAddress(); + if (!allowedIps.includes(clientIp)) { + logger.warn( + { clientIp, method: event.request.method, path: event.url.pathname }, + 'Internal API request rejected: IP not in whitelist', + ); + return json({ message: 'Forbidden' }, { status: 403 }); + } + + return handler(event); + }; +} diff --git a/src/routes/(main)/api/extract/collections/+server.ts b/src/routes/(main)/api/extract/collections/+server.ts new file mode 100644 index 00000000..d50fb2cd --- /dev/null +++ b/src/routes/(main)/api/extract/collections/+server.ts @@ -0,0 +1,90 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { CollectionFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + buildUpdatedAtWhere, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parseDate, + parsePositiveInteger, +} from '../helpers.js'; + +const getCollectionsApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_collections' }); + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const lastUpdatedStartResult = parseDate( + event.url.searchParams.get('lastUpdatedStart'), + 'lastUpdatedStart', + ); + if ('error' in lastUpdatedStartResult) { + return json({ message: lastUpdatedStartResult.error }, { status: 400 }); + } + + const lastUpdatedEndResult = parseDate( + event.url.searchParams.get('lastUpdatedEnd'), + 'lastUpdatedEnd', + ); + if ('error' in lastUpdatedEndResult) { + return json({ message: lastUpdatedEndResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const where = buildUpdatedAtWhere(lastUpdatedStartResult.value, lastUpdatedEndResult.value); + + const collectionArgs = { + where, + orderBy: { + updatedAt: 'asc', + }, + skip, + take: pageSize, + } satisfies CollectionFindManyArgs; + + try { + const [collections, totalCount] = await Promise.all([ + db.collection.findMany(collectionArgs), + db.collection.count({ where }), + ]); + + return json({ + data: collections, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(lastUpdatedStartResult.value, lastUpdatedEndResult.value), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract collections'); + return json({ message: 'Failed to extract collections.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getCollectionsApi, +); diff --git a/src/routes/(main)/api/extract/collections/collections.test.ts b/src/routes/(main)/api/extract/collections/collections.test.ts new file mode 100644 index 00000000..44d1989e --- /dev/null +++ b/src/routes/(main)/api/extract/collections/collections.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + collection: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/collections', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.collection.findMany).mockReset(); + vi.mocked(db.collection.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract collections GET', () => { + test('returns paginated collections', async () => { + vi.mocked(db.collection.findMany).mockResolvedValue([ + { + id: 'collection-1', + title: 'Collection A', + description: 'Description', + tagId: null, + isTopic: false, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]); + vi.mocked(db.collection.count).mockResolvedValue(3); + + const response = await GET( + makeEvent('http://localhost/api/extract/collections?page=2&pageSize=1', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.collection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + orderBy: { updatedAt: 'asc' }, + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); + + test('filters collections by updatedAt range', async () => { + vi.mocked(db.collection.findMany).mockResolvedValue([]); + vi.mocked(db.collection.count).mockResolvedValue(0); + + const response = await GET( + makeEvent( + 'http://localhost/api/extract/collections?lastUpdatedStart=2026-01-01T00:00:00.000Z&lastUpdatedEnd=2026-01-31T23:59:59.999Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.collection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + updatedAt: { + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-01-31T23:59:59.999Z'), + }, + }, + }), + ); + expect(body.filters).toEqual({ + lastUpdatedStart: '2026-01-01T00:00:00.000Z', + lastUpdatedEnd: '2026-01-31T23:59:59.999Z', + }); + }); + + test('returns bad request for invalid pageSize', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/collections?pageSize=0', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid pageSize parameter.' }); + expect(db.collection.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/(main)/api/extract/extract-auth-status.test.ts b/src/routes/(main)/api/extract/extract-auth-status.test.ts new file mode 100644 index 00000000..ba7fb3ab --- /dev/null +++ b/src/routes/(main)/api/extract/extract-auth-status.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: {}, +})); + +type ExtractGetHandler = (event: never) => Promise; + +const routes = [ + { + name: 'collections', + path: './collections/+server.js', + url: 'http://localhost/api/extract/collections', + }, + { + name: 'learning-journeys', + path: './learning-journeys/+server.js', + url: 'http://localhost/api/extract/learning-journeys', + }, + { + name: 'learning-unit-collections', + path: './learning-unit-collections/+server.js', + url: 'http://localhost/api/extract/learning-unit-collections', + }, + { + name: 'learning-unit-sentiments', + path: './learning-unit-sentiments/+server.js', + url: 'http://localhost/api/extract/learning-unit-sentiments', + }, + { + name: 'learning-units', + path: './learning-units/+server.js', + url: 'http://localhost/api/extract/learning-units', + }, + { + name: 'users', + path: './users/+server.js', + url: 'http://localhost/api/extract/users', + }, +] as const; + +function makeEvent(url: string, headers: Record = {}, clientIp = '127.0.0.1') { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +async function loadGetHandler(path: string): Promise { + const module = (await import(path)) as { GET: ExtractGetHandler }; + return module.GET; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1,::1'); +}); + +describe('extract API auth status', () => { + test.each(routes)('%s returns 401 when API key is missing', async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET(makeEvent(url)); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ message: 'Unauthorized' }); + }); + + test.each(routes)('%s returns 403 when client IP is not whitelisted', async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET(makeEvent(url, { 'x-api-key': 'test-api-key' }, '10.0.0.1')); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ message: 'Forbidden' }); + }); +}); diff --git a/src/routes/(main)/api/extract/extract-query-validation.test.ts b/src/routes/(main)/api/extract/extract-query-validation.test.ts new file mode 100644 index 00000000..1a4eb420 --- /dev/null +++ b/src/routes/(main)/api/extract/extract-query-validation.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const { dbMock } = vi.hoisted(() => ({ + dbMock: { + collection: { + findMany: vi.fn(), + count: vi.fn(), + }, + learningJourney: { + findMany: vi.fn(), + count: vi.fn(), + }, + learningUnitCollection: { + findMany: vi.fn(), + count: vi.fn(), + }, + learningUnitSentiments: { + findMany: vi.fn(), + count: vi.fn(), + }, + learningUnit: { + findMany: vi.fn(), + count: vi.fn(), + }, + user: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: dbMock, +})); + +type ExtractGetHandler = (event: never) => Promise; +type DbModelKey = keyof typeof dbMock; + +const routes = [ + { + name: 'collections', + path: './collections/+server.js', + url: 'http://localhost/api/extract/collections', + model: 'collection' as DbModelKey, + supportsUpdatedAtFilters: true, + failureMessage: 'Failed to extract collections.', + }, + { + name: 'learning-journeys', + path: './learning-journeys/+server.js', + url: 'http://localhost/api/extract/learning-journeys', + model: 'learningJourney' as DbModelKey, + supportsUpdatedAtFilters: true, + failureMessage: 'Failed to extract learning journeys.', + }, + { + name: 'learning-unit-collections', + path: './learning-unit-collections/+server.js', + url: 'http://localhost/api/extract/learning-unit-collections', + model: 'learningUnitCollection' as DbModelKey, + supportsUpdatedAtFilters: false, + failureMessage: 'Failed to extract learning unit collections.', + }, + { + name: 'learning-unit-sentiments', + path: './learning-unit-sentiments/+server.js', + url: 'http://localhost/api/extract/learning-unit-sentiments', + model: 'learningUnitSentiments' as DbModelKey, + supportsUpdatedAtFilters: true, + failureMessage: 'Failed to extract learning unit sentiments.', + }, + { + name: 'learning-units', + path: './learning-units/+server.js', + url: 'http://localhost/api/extract/learning-units', + model: 'learningUnit' as DbModelKey, + supportsUpdatedAtFilters: true, + failureMessage: 'Failed to extract learning units.', + }, + { + name: 'users', + path: './users/+server.js', + url: 'http://localhost/api/extract/users', + model: 'user' as DbModelKey, + supportsUpdatedAtFilters: true, + failureMessage: 'Failed to extract users.', + }, +] as const; + +function makeEvent(url: string, headers: Record = {}, clientIp = '127.0.0.1') { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +async function loadGetHandler(path: string): Promise { + const module = (await import(path)) as { GET: ExtractGetHandler }; + return module.GET; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1,::1'); + + for (const model of Object.values(dbMock)) { + model.findMany.mockReset(); + model.count.mockReset(); + model.findMany.mockResolvedValue([]); + model.count.mockResolvedValue(0); + } +}); + +describe('extract API query validation and failures', () => { + test.each(routes)('%s returns 400 for invalid page', async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET(makeEvent(`${url}?page=0`, { 'x-api-key': 'test-api-key' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid page parameter.' }); + }); + + test.each(routes)('%s returns 400 for invalid pageSize', async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET(makeEvent(`${url}?pageSize=0`, { 'x-api-key': 'test-api-key' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid pageSize parameter.' }); + }); + + test.each(routes.filter((route) => route.supportsUpdatedAtFilters))( + '%s returns 400 for invalid lastUpdatedStart', + async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET( + makeEvent(`${url}?lastUpdatedStart=not-a-date`, { 'x-api-key': 'test-api-key' }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid lastUpdatedStart parameter.' }); + }, + ); + + test.each(routes.filter((route) => route.supportsUpdatedAtFilters))( + '%s returns 400 for invalid lastUpdatedEnd', + async ({ path, url }) => { + const GET = await loadGetHandler(path); + + const response = await GET( + makeEvent(`${url}?lastUpdatedEnd=not-a-date`, { 'x-api-key': 'test-api-key' }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid lastUpdatedEnd parameter.' }); + }, + ); + + test.each(routes)( + '%s returns 500 when query fails', + async ({ path, url, model, failureMessage }) => { + dbMock[model].findMany.mockRejectedValueOnce(new Error('db error')); + const GET = await loadGetHandler(path); + + const response = await GET(makeEvent(url, { 'x-api-key': 'test-api-key' })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ message: failureMessage }); + }, + ); +}); diff --git a/src/routes/(main)/api/extract/helpers.ts b/src/routes/(main)/api/extract/helpers.ts new file mode 100644 index 00000000..a898e78d --- /dev/null +++ b/src/routes/(main)/api/extract/helpers.ts @@ -0,0 +1,67 @@ +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 100; +export const MAX_PAGE_SIZE = 1000; + +type ParseResult = { value: T } | { error: string }; + +export function parsePositiveInteger( + value: string | null, + fallback: number, + name: string, +): ParseResult { + if (value === null) { + return { value: fallback }; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + return { error: `Invalid ${name} parameter.` }; + } + + return { value: parsed }; +} + +export function parseDate(value: string | null, name: string): ParseResult { + if (!value) { + return { value: null }; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { error: `Invalid ${name} parameter.` }; + } + + return { value: parsed }; +} + +export function buildUpdatedAtWhere(lastUpdatedStart: Date | null, lastUpdatedEnd: Date | null) { + const updatedAt: { gte?: Date; lte?: Date } = {}; + + if (lastUpdatedStart) { + updatedAt.gte = lastUpdatedStart; + } + + if (lastUpdatedEnd) { + updatedAt.lte = lastUpdatedEnd; + } + + return Object.keys(updatedAt).length > 0 ? { updatedAt } : undefined; +} + +export function buildPagination(page: number, pageSize: number, totalCount: number) { + return { + page, + pageSize, + totalCount, + totalPages: Math.ceil(totalCount / pageSize), + hasNextPage: page * pageSize < totalCount, + hasPreviousPage: page > 1, + }; +} + +export function buildUpdatedAtFilters(lastUpdatedStart: Date | null, lastUpdatedEnd: Date | null) { + return { + lastUpdatedStart: lastUpdatedStart?.toISOString() ?? null, + lastUpdatedEnd: lastUpdatedEnd?.toISOString() ?? null, + }; +} diff --git a/src/routes/(main)/api/extract/learning-journeys/+server.ts b/src/routes/(main)/api/extract/learning-journeys/+server.ts new file mode 100644 index 00000000..670afbb6 --- /dev/null +++ b/src/routes/(main)/api/extract/learning-journeys/+server.ts @@ -0,0 +1,90 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { LearningJourneyFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + buildUpdatedAtWhere, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parseDate, + parsePositiveInteger, +} from '../helpers.js'; + +const getLearningJourneysApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_learning_journeys' }); + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const lastUpdatedStartResult = parseDate( + event.url.searchParams.get('lastUpdatedStart'), + 'lastUpdatedStart', + ); + if ('error' in lastUpdatedStartResult) { + return json({ message: lastUpdatedStartResult.error }, { status: 400 }); + } + + const lastUpdatedEndResult = parseDate( + event.url.searchParams.get('lastUpdatedEnd'), + 'lastUpdatedEnd', + ); + if ('error' in lastUpdatedEndResult) { + return json({ message: lastUpdatedEndResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const where = buildUpdatedAtWhere(lastUpdatedStartResult.value, lastUpdatedEndResult.value); + + const learningJourneyArgs = { + where, + orderBy: { + updatedAt: 'asc', + }, + skip, + take: pageSize, + } satisfies LearningJourneyFindManyArgs; + + try { + const [learningJourneys, totalCount] = await Promise.all([ + db.learningJourney.findMany(learningJourneyArgs), + db.learningJourney.count({ where }), + ]); + + return json({ + data: learningJourneys, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(lastUpdatedStartResult.value, lastUpdatedEndResult.value), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract learning journeys'); + return json({ message: 'Failed to extract learning journeys.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getLearningJourneysApi, +); diff --git a/src/routes/(main)/api/extract/learning-journeys/learning-journeys.test.ts b/src/routes/(main)/api/extract/learning-journeys/learning-journeys.test.ts new file mode 100644 index 00000000..c690fdbe --- /dev/null +++ b/src/routes/(main)/api/extract/learning-journeys/learning-journeys.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningJourney: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/learning-journeys', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.learningJourney.findMany).mockReset(); + vi.mocked(db.learningJourney.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract learning journeys GET', () => { + test('returns paginated learning journeys', async () => { + vi.mocked(db.learningJourney.findMany).mockResolvedValue([ + { + id: 'journey-1', + userId: 'user-1', + learningUnitId: 'unit-1', + isCompleted: false, + isQuizAttempted: false, + isQuizPassed: null, + numberOfAttempts: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]); + vi.mocked(db.learningJourney.count).mockResolvedValue(4); + + const response = await GET( + makeEvent('http://localhost/api/extract/learning-journeys?page=2&pageSize=2', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningJourney.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 2, + take: 2, + orderBy: { updatedAt: 'asc' }, + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 2, + totalCount: 4, + totalPages: 2, + hasNextPage: false, + hasPreviousPage: true, + }); + expect(body.data).toHaveLength(1); + }); + + test('filters learning journeys by updatedAt range', async () => { + vi.mocked(db.learningJourney.findMany).mockResolvedValue([]); + vi.mocked(db.learningJourney.count).mockResolvedValue(0); + + const response = await GET( + makeEvent( + 'http://localhost/api/extract/learning-journeys?lastUpdatedStart=2026-01-01T00:00:00.000Z&lastUpdatedEnd=2026-01-31T23:59:59.999Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningJourney.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + updatedAt: { + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-01-31T23:59:59.999Z'), + }, + }, + }), + ); + expect(body.filters).toEqual({ + lastUpdatedStart: '2026-01-01T00:00:00.000Z', + lastUpdatedEnd: '2026-01-31T23:59:59.999Z', + }); + }); + + test('returns bad request for invalid page', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/learning-journeys?page=0', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid page parameter.' }); + expect(db.learningJourney.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/(main)/api/extract/learning-unit-collections/+server.ts b/src/routes/(main)/api/extract/learning-unit-collections/+server.ts new file mode 100644 index 00000000..f93890cb --- /dev/null +++ b/src/routes/(main)/api/extract/learning-unit-collections/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { LearningUnitCollectionFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parsePositiveInteger, +} from '../helpers.js'; + +const getLearningUnitCollectionsApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_learning_unit_collections' }); + + if ( + event.url.searchParams.has('lastUpdatedStart') || + event.url.searchParams.has('lastUpdatedEnd') + ) { + return json( + { + message: + 'lastUpdatedStart and lastUpdatedEnd filters are not supported for learning unit collections.', + }, + { status: 400 }, + ); + } + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const learningUnitCollectionArgs = { + orderBy: [{ learningUnitId: 'asc' }, { collectionId: 'asc' }], + skip, + take: pageSize, + } satisfies LearningUnitCollectionFindManyArgs; + + try { + const [learningUnitCollections, totalCount] = await Promise.all([ + db.learningUnitCollection.findMany(learningUnitCollectionArgs), + db.learningUnitCollection.count(), + ]); + + return json({ + data: learningUnitCollections, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(null, null), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract learning unit collections'); + return json({ message: 'Failed to extract learning unit collections.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getLearningUnitCollectionsApi, +); diff --git a/src/routes/(main)/api/extract/learning-unit-collections/learning-unit-collections.test.ts b/src/routes/(main)/api/extract/learning-unit-collections/learning-unit-collections.test.ts new file mode 100644 index 00000000..1cfddff3 --- /dev/null +++ b/src/routes/(main)/api/extract/learning-unit-collections/learning-unit-collections.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningUnitCollection: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/learning-unit-collections', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.learningUnitCollection.findMany).mockReset(); + vi.mocked(db.learningUnitCollection.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract learning unit collections GET', () => { + test('returns paginated learning unit collections', async () => { + vi.mocked(db.learningUnitCollection.findMany).mockResolvedValue([ + { + learningUnitId: 'unit-1', + collectionId: 'collection-1', + }, + ]); + vi.mocked(db.learningUnitCollection.count).mockResolvedValue(3); + + const response = await GET( + makeEvent('http://localhost/api/extract/learning-unit-collections?page=2&pageSize=1', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningUnitCollection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + orderBy: [{ learningUnitId: 'asc' }, { collectionId: 'asc' }], + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); + + test('returns bad request for unsupported lastUpdated filters', async () => { + const response = await GET( + makeEvent( + 'http://localhost/api/extract/learning-unit-collections?lastUpdatedStart=2026-01-01T00:00:00.000Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + message: + 'lastUpdatedStart and lastUpdatedEnd filters are not supported for learning unit collections.', + }); + expect(db.learningUnitCollection.findMany).not.toHaveBeenCalled(); + }); + + test('returns bad request for invalid page', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/learning-unit-collections?page=0', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid page parameter.' }); + expect(db.learningUnitCollection.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/(main)/api/extract/learning-unit-sentiments/+server.ts b/src/routes/(main)/api/extract/learning-unit-sentiments/+server.ts new file mode 100644 index 00000000..745ccf1b --- /dev/null +++ b/src/routes/(main)/api/extract/learning-unit-sentiments/+server.ts @@ -0,0 +1,88 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { LearningUnitSentimentsFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + buildUpdatedAtWhere, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parseDate, + parsePositiveInteger, +} from '../helpers.js'; + +const getLearningUnitSentimentsApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_learning_unit_sentiments' }); + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const lastUpdatedStartResult = parseDate( + event.url.searchParams.get('lastUpdatedStart'), + 'lastUpdatedStart', + ); + if ('error' in lastUpdatedStartResult) { + return json({ message: lastUpdatedStartResult.error }, { status: 400 }); + } + + const lastUpdatedEndResult = parseDate( + event.url.searchParams.get('lastUpdatedEnd'), + 'lastUpdatedEnd', + ); + if ('error' in lastUpdatedEndResult) { + return json({ message: lastUpdatedEndResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const where = buildUpdatedAtWhere(lastUpdatedStartResult.value, lastUpdatedEndResult.value); + + const learningUnitSentimentsArgs = { + where, + orderBy: [{ updatedAt: 'asc' }, { userId: 'asc' }, { learningUnitId: 'asc' }], + skip, + take: pageSize, + } satisfies LearningUnitSentimentsFindManyArgs; + + try { + const [learningUnitSentiments, totalCount] = await Promise.all([ + db.learningUnitSentiments.findMany(learningUnitSentimentsArgs), + db.learningUnitSentiments.count({ where }), + ]); + + return json({ + data: learningUnitSentiments, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(lastUpdatedStartResult.value, lastUpdatedEndResult.value), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract learning unit sentiments'); + return json({ message: 'Failed to extract learning unit sentiments.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getLearningUnitSentimentsApi, +); diff --git a/src/routes/(main)/api/extract/learning-unit-sentiments/learning-unit-sentiments.test.ts b/src/routes/(main)/api/extract/learning-unit-sentiments/learning-unit-sentiments.test.ts new file mode 100644 index 00000000..c453bf31 --- /dev/null +++ b/src/routes/(main)/api/extract/learning-unit-sentiments/learning-unit-sentiments.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningUnitSentiments: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/learning-unit-sentiments', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.learningUnitSentiments.findMany).mockReset(); + vi.mocked(db.learningUnitSentiments.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract learning unit sentiments GET', () => { + test('returns paginated learning unit sentiments', async () => { + vi.mocked(db.learningUnitSentiments.findMany).mockResolvedValue([ + { + userId: 'user-1', + learningUnitId: 'unit-1', + hasLiked: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]); + vi.mocked(db.learningUnitSentiments.count).mockResolvedValue(3); + + const response = await GET( + makeEvent('http://localhost/api/extract/learning-unit-sentiments?page=2&pageSize=1', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningUnitSentiments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + orderBy: [{ updatedAt: 'asc' }, { userId: 'asc' }, { learningUnitId: 'asc' }], + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); + + test('filters learning unit sentiments by updatedAt range', async () => { + vi.mocked(db.learningUnitSentiments.findMany).mockResolvedValue([]); + vi.mocked(db.learningUnitSentiments.count).mockResolvedValue(0); + + const response = await GET( + makeEvent( + 'http://localhost/api/extract/learning-unit-sentiments?lastUpdatedStart=2026-01-01T00:00:00.000Z&lastUpdatedEnd=2026-01-31T23:59:59.999Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningUnitSentiments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + updatedAt: { + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-01-31T23:59:59.999Z'), + }, + }, + }), + ); + expect(body.filters).toEqual({ + lastUpdatedStart: '2026-01-01T00:00:00.000Z', + lastUpdatedEnd: '2026-01-31T23:59:59.999Z', + }); + }); + + test('returns bad request for invalid lastUpdatedStart', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/learning-unit-sentiments?lastUpdatedStart=bad', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid lastUpdatedStart parameter.' }); + expect(db.learningUnitSentiments.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/(main)/api/extract/learning-units/+server.ts b/src/routes/(main)/api/extract/learning-units/+server.ts new file mode 100644 index 00000000..c0e31f32 --- /dev/null +++ b/src/routes/(main)/api/extract/learning-units/+server.ts @@ -0,0 +1,90 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { LearningUnitFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + buildUpdatedAtWhere, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parseDate, + parsePositiveInteger, +} from '../helpers.js'; + +const getLearningUnitsApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_learning_units' }); + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const lastUpdatedStartResult = parseDate( + event.url.searchParams.get('lastUpdatedStart'), + 'lastUpdatedStart', + ); + if ('error' in lastUpdatedStartResult) { + return json({ message: lastUpdatedStartResult.error }, { status: 400 }); + } + + const lastUpdatedEndResult = parseDate( + event.url.searchParams.get('lastUpdatedEnd'), + 'lastUpdatedEnd', + ); + if ('error' in lastUpdatedEndResult) { + return json({ message: lastUpdatedEndResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const where = buildUpdatedAtWhere(lastUpdatedStartResult.value, lastUpdatedEndResult.value); + + const learningUnitArgs = { + where, + orderBy: { + updatedAt: 'asc', + }, + skip, + take: pageSize, + } satisfies LearningUnitFindManyArgs; + + try { + const [learningUnits, totalCount] = await Promise.all([ + db.learningUnit.findMany(learningUnitArgs), + db.learningUnit.count({ where }), + ]); + + return json({ + data: learningUnits, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(lastUpdatedStartResult.value, lastUpdatedEndResult.value), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract learning units'); + return json({ message: 'Failed to extract learning units.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getLearningUnitsApi, +); diff --git a/src/routes/(main)/api/extract/learning-units/learning-units.test.ts b/src/routes/(main)/api/extract/learning-units/learning-units.test.ts new file mode 100644 index 00000000..16f07fcb --- /dev/null +++ b/src/routes/(main)/api/extract/learning-units/learning-units.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + learningUnit: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/learning-units', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.learningUnit.findMany).mockReset(); + vi.mocked(db.learningUnit.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract learning units GET', () => { + test('returns paginated learning units', async () => { + vi.mocked(db.learningUnit.findMany).mockResolvedValue([ + { + id: 'unit-1', + title: 'Unit A', + summary: 'Summary', + objectives: 'Objectives', + createdBy: 'Admin', + isRecommended: false, + isRequired: false, + status: 'PUBLISHED', + dueDate: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]); + vi.mocked(db.learningUnit.count).mockResolvedValue(3); + + const response = await GET( + makeEvent('http://localhost/api/extract/learning-units?page=2&pageSize=1', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningUnit.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + orderBy: { updatedAt: 'asc' }, + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + expect(body.data).toHaveLength(1); + }); + + test('filters learning units by updatedAt range', async () => { + vi.mocked(db.learningUnit.findMany).mockResolvedValue([]); + vi.mocked(db.learningUnit.count).mockResolvedValue(0); + + const response = await GET( + makeEvent( + 'http://localhost/api/extract/learning-units?lastUpdatedStart=2026-01-01T00:00:00.000Z&lastUpdatedEnd=2026-01-31T23:59:59.999Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.learningUnit.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + updatedAt: { + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-01-31T23:59:59.999Z'), + }, + }, + }), + ); + expect(body.filters).toEqual({ + lastUpdatedStart: '2026-01-01T00:00:00.000Z', + lastUpdatedEnd: '2026-01-31T23:59:59.999Z', + }); + }); + + test('returns bad request for invalid lastUpdatedEnd', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/learning-units?lastUpdatedEnd=not-a-date', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid lastUpdatedEnd parameter.' }); + expect(db.learningUnit.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/(main)/api/extract/users/+server.ts b/src/routes/(main)/api/extract/users/+server.ts new file mode 100644 index 00000000..272795dd --- /dev/null +++ b/src/routes/(main)/api/extract/users/+server.ts @@ -0,0 +1,90 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +import type { UserFindManyArgs } from '$lib/server/db.js'; +import { db } from '$lib/server/db.js'; +import { + composeMiddleware, + withInternalApiKey, + withIpWhitelist, +} from '$lib/server/middleware/index.js'; + +import { + buildPagination, + buildUpdatedAtFilters, + buildUpdatedAtWhere, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + parseDate, + parsePositiveInteger, +} from '../helpers.js'; + +const getUsersApi: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_extract_users' }); + + const pageResult = parsePositiveInteger(event.url.searchParams.get('page'), DEFAULT_PAGE, 'page'); + if ('error' in pageResult) { + return json({ message: pageResult.error }, { status: 400 }); + } + + const pageSizeResult = parsePositiveInteger( + event.url.searchParams.get('pageSize'), + DEFAULT_PAGE_SIZE, + 'pageSize', + ); + if ('error' in pageSizeResult) { + return json({ message: pageSizeResult.error }, { status: 400 }); + } + + const lastUpdatedStartResult = parseDate( + event.url.searchParams.get('lastUpdatedStart'), + 'lastUpdatedStart', + ); + if ('error' in lastUpdatedStartResult) { + return json({ message: lastUpdatedStartResult.error }, { status: 400 }); + } + + const lastUpdatedEndResult = parseDate( + event.url.searchParams.get('lastUpdatedEnd'), + 'lastUpdatedEnd', + ); + if ('error' in lastUpdatedEndResult) { + return json({ message: lastUpdatedEndResult.error }, { status: 400 }); + } + + const page = pageResult.value; + const pageSize = Math.min(pageSizeResult.value, MAX_PAGE_SIZE); + const skip = (page - 1) * pageSize; + + const where = buildUpdatedAtWhere(lastUpdatedStartResult.value, lastUpdatedEndResult.value); + + const userArgs = { + where, + orderBy: { + updatedAt: 'asc', + }, + skip, + take: pageSize, + } satisfies UserFindManyArgs; + + try { + const [users, totalCount] = await Promise.all([ + db.user.findMany(userArgs), + db.user.count({ where }), + ]); + + return json({ + data: users, + pagination: buildPagination(page, pageSize, totalCount), + filters: buildUpdatedAtFilters(lastUpdatedStartResult.value, lastUpdatedEndResult.value), + }); + } catch (err) { + logger.error({ err }, 'Failed to extract users'); + return json({ message: 'Failed to extract users.' }, { status: 500 }); + } +}; + +export const GET: RequestHandler = composeMiddleware([withIpWhitelist, withInternalApiKey])( + getUsersApi, +); diff --git a/src/routes/(main)/api/extract/users/users.test.ts b/src/routes/(main)/api/extract/users/users.test.ts new file mode 100644 index 00000000..7ca79613 --- /dev/null +++ b/src/routes/(main)/api/extract/users/users.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: new Proxy( + {}, + { + get: (_target, prop) => process.env[prop as string], + }, + ), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + user: { + findMany: vi.fn(), + count: vi.fn(), + }, + }, +})); + +import { db } from '$lib/server/db.js'; + +import { GET } from './+server.js'; + +function makeEvent( + url = 'http://localhost/api/extract/users', + headers: Record = {}, + clientIp = '127.0.0.1', +) { + const childLogger = { warn: vi.fn(), error: vi.fn() }; + const logger = { warn: vi.fn(), error: vi.fn(), child: vi.fn().mockReturnValue(childLogger) }; + return { + request: new Request(url, { headers }), + getClientAddress: () => clientIp, + url: new URL(url), + locals: { logger }, + } as never; +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.mocked(db.user.findMany).mockReset(); + vi.mocked(db.user.count).mockReset(); + vi.stubEnv('EXTRACT_API_KEY', 'test-api-key'); + vi.stubEnv('EXTRACT_API_ALLOWED_IPS', '127.0.0.1'); +}); + +describe('extract users GET', () => { + test('returns paginated users', async () => { + vi.mocked(db.user.findMany).mockResolvedValue([ + { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + googleProviderId: 'google-1', + avatarURL: 'https://example.com/alice.png', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]); + vi.mocked(db.user.count).mockResolvedValue(3); + + const response = await GET( + makeEvent('http://localhost/api/extract/users?page=2&pageSize=1', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + orderBy: { updatedAt: 'asc' }, + }), + ); + expect(body.pagination).toEqual({ + page: 2, + pageSize: 1, + totalCount: 3, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + expect(body.data).toHaveLength(1); + }); + + test('filters users by updatedAt range', async () => { + vi.mocked(db.user.findMany).mockResolvedValue([]); + vi.mocked(db.user.count).mockResolvedValue(0); + + const response = await GET( + makeEvent( + 'http://localhost/api/extract/users?lastUpdatedStart=2026-01-01T00:00:00.000Z&lastUpdatedEnd=2026-01-31T23:59:59.999Z', + { + 'x-api-key': 'test-api-key', + }, + ), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + updatedAt: { + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-01-31T23:59:59.999Z'), + }, + }, + }), + ); + expect(body.filters).toEqual({ + lastUpdatedStart: '2026-01-01T00:00:00.000Z', + lastUpdatedEnd: '2026-01-31T23:59:59.999Z', + }); + }); + + test('returns bad request for invalid lastUpdatedStart', async () => { + const response = await GET( + makeEvent('http://localhost/api/extract/users?lastUpdatedStart=not-a-date', { + 'x-api-key': 'test-api-key', + }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ message: 'Invalid lastUpdatedStart parameter.' }); + expect(db.user.findMany).not.toHaveBeenCalled(); + }); +});