Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ AQUIFER_API_URL=https://api.aquifer.bible
# Request a key at https://www.aquifer.bible/apiaccess
AQUIFER_API_KEY=

# ── YouVersion (Bible text for the Reference column) ─────────────────────────
# Base URL of the YouVersion API (no trailing slash). Defaults to production.
YOUVERSION_API_URL=https://api.youversion.com/v1
# Server-held YouVersion API key — never expose to mobile/web clients.
# Optional: leave blank to boot without YouVersion; Bible text routes return 502.
YOUVERSION_API_KEY=

# ── API.Bible (DBL) Integration ──────────────────────────────────────────────
# Base URL for the API.Bible endpoint. Defaults to v1.
DBL_API_BASE_URL=https://rest.api.bible/v1
Expand Down
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ FLUENT_AI_KEY=test-only-dummy-fluent-ai-key
AI_INBOUND_SERVICE_KEY=test-only-dummy-inbound-key
AQUIFER_API_URL=https://api.aquifer.bible
AQUIFER_API_KEY=test-only-dummy-aquifer-api-key
YOUVERSION_API_URL=https://api.youversion.com/v1
YOUVERSION_API_KEY=test-only-dummy-youversion-api-key
DBL_API_KEY=test-key
DBL_API_TIMEOUT_MS=30000
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import '@/domains/translation-resources/translation-resources.route';
import '@/domains/source-audio/source-audio.route';
import '@/domains/self/settings/self-settings.route';
import '@/domains/aquifer-resources/aquifer-resources.route';
import '@/domains/youversion/youversion.route';
configureOpenAPI(server);

export default server;
195 changes: 195 additions & 0 deletions src/domains/youversion/youversion.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { findGrantsByUserId } from '@/domains/user-roles/user-roles.repository';
import { getUserByEmail } from '@/domains/users/users.service';
import { auth } from '@/lib/auth';
import { PERMISSIONS } from '@/lib/permissions';
import * as youVersionClient from '@/lib/services/youversion/youversion.client';
import { err, ErrorCode, ok } from '@/lib/types';
import { server } from '@/server/server';
import '@/domains/youversion/youversion.route';

vi.mock('@/lib/auth', () => ({
auth: {
api: { getSession: vi.fn() },
handler: vi.fn(),
},
}));

vi.mock('@/db', () => {
const mockQueryBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ activeOrgId: 1 }]),
};
return {
db: { select: vi.fn(() => mockQueryBuilder), insert: vi.fn(), update: vi.fn() },
};
});

vi.mock('@/lib/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));

vi.mock('@/domains/users/users.service', () => ({
getUserByEmail: vi.fn(),
}));

vi.mock('@/domains/user-roles/user-roles.repository', () => ({
findGrantsByUserId: vi.fn(),
}));

vi.mock('@/lib/services/youversion/youversion.client', () => ({
getBibles: vi.fn(),
getChapterText: vi.fn(),
isYouVersionConfigured: vi.fn(),
}));

const MOCK_USER = {
id: 1,
email: 'test@example.com',
role: 5,
roleName: 'Translator',
organization: 1,
status: 'verified' as const,
};

function authenticateUserMock(hasPermission = true) {
vi.mocked(auth.api.getSession as any).mockResolvedValue({
session: { id: 's1', updatedAt: new Date(), expiresAt: new Date(Date.now() + 1e9) },
user: { email: MOCK_USER.email },
});
vi.mocked(getUserByEmail as any).mockResolvedValue(ok(MOCK_USER));
vi.mocked(findGrantsByUserId as any).mockResolvedValue(
ok(
hasPermission
? [{ orgId: 1, projectId: 1, permissions: new Set([PERMISSIONS.CONTENT_VIEW]) }]
: []
)
);
}

describe('youversion routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});

// ─── Auth gates ─────────────────────────────────────────────────────────────

describe('auth gates', () => {
it('returns 401 when unauthenticated', async () => {
vi.mocked(auth.api.getSession as any).mockResolvedValue(null);

const res = await server.request('/youversion/bibles?language_tag=eng');

expect(res.status).toBe(401);
expect(youVersionClient.getBibles).not.toHaveBeenCalled();
});

it('returns 403 when user lacks CONTENT_VIEW permission', async () => {
authenticateUserMock(false);

const res = await server.request('/youversion/bibles?language_tag=eng');

expect(res.status).toBe(403);
expect(youVersionClient.getBibles).not.toHaveBeenCalled();
});
});

// ─── GET /youversion/bibles ──────────────────────────────────────────────────

describe('get /youversion/bibles', () => {
it('returns bibles list and sets Cache-Control header', async () => {
authenticateUserMock();
const mockBibles = [
{
id: 1,
abbreviation: 'NIV',
localized_abbreviation: 'NIV',
title: 'New International Version',
localized_title: 'New International Version',
language_tag: 'eng',
},
];
vi.mocked(youVersionClient.getBibles).mockResolvedValue(ok(mockBibles));

const res = await server.request('/youversion/bibles?language_tag=eng');

expect(res.status).toBe(200);
expect(res.headers.get('Cache-Control')).toBe('private, max-age=300');
expect(youVersionClient.getBibles).toHaveBeenCalledWith('eng');
const data = await res.json();
expect(data).toEqual(mockBibles);
});

it('returns 400 when language_tag query param is missing', async () => {
authenticateUserMock();

const res = await server.request('/youversion/bibles');

expect(res.status).toBe(400);
expect(youVersionClient.getBibles).not.toHaveBeenCalled();
});

it('returns 502 Bad Gateway on YouVersion failure', async () => {
authenticateUserMock();
vi.mocked(youVersionClient.getBibles).mockResolvedValue(
err(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE)
);

const res = await server.request('/youversion/bibles?language_tag=eng');

expect(res.status).toBe(502);
const data = await res.json();
expect(data).toHaveProperty('message');
});
});

// ─── GET /youversion/bibles/{bibleId}/chapters/{chapterId}/text ──────────────

describe('get /youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text', () => {
it('returns chapter text and sets Cache-Control header', async () => {
authenticateUserMock();
const mockChapterText = {
bibleId: 1,
bookId: 'GEN',
chapterId: 1,
verses: [
{ verseNumber: 1, passageId: 'GEN.1.1', content: 'In the beginning...' },
{ verseNumber: 2, passageId: 'GEN.1.2', content: 'Now the earth was...' },
],
};
vi.mocked(youVersionClient.getChapterText).mockResolvedValue(ok(mockChapterText));

const res = await server.request('/youversion/bibles/1/books/GEN/chapters/1/text');

expect(res.status).toBe(200);
expect(res.headers.get('Cache-Control')).toBe('private, max-age=300');
expect(youVersionClient.getChapterText).toHaveBeenCalledWith(1, 'GEN', 1);
const data = await res.json();
expect(data).toEqual(mockChapterText);
});

it('returns 404 when bookId path segment is absent', async () => {
authenticateUserMock();

const res = await server.request('/youversion/bibles/1/chapters/1/text');

expect(res.status).toBe(404);
expect(youVersionClient.getChapterText).not.toHaveBeenCalled();
});

it('returns 502 Bad Gateway on YouVersion failure', async () => {
authenticateUserMock();
vi.mocked(youVersionClient.getChapterText).mockResolvedValue(
err(ErrorCode.YOUVERSION_SERVICE_UNAVAILABLE)
);

const res = await server.request('/youversion/bibles/1/books/GEN/chapters/1/text');

expect(res.status).toBe(502);
const data = await res.json();
expect(data).toHaveProperty('message');
});
});
});
105 changes: 105 additions & 0 deletions src/domains/youversion/youversion.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { createRoute, z } from '@hono/zod-openapi';
import * as HttpStatusCodes from 'stoker/http-status-codes';
import * as HttpStatusPhrases from 'stoker/http-status-phrases';
import { jsonContent } from 'stoker/openapi/helpers';
import { createMessageObjectSchema } from 'stoker/openapi/schemas';

import { PERMISSIONS } from '@/lib/permissions';
import { youVersionErrorResponse } from '@/lib/services/youversion/youversion.errors';
import { authenticateUser, requirePermission } from '@/middlewares/role-auth';
import { server } from '@/server/server';

import * as youVersionService from './youversion.service';
import {
biblesQuerySchema,
chapterTextParamSchema,
youVersionBibleSchema,
youVersionChapterTextSchema,
} from './youversion.types';

const badRequestResponse = jsonContent(
createMessageObjectSchema('Bad Request'),
'Invalid request parameters'
);
const unauthorizedResponse = jsonContent(
createMessageObjectSchema('Unauthorized'),
'Authentication required'
);
const forbiddenResponse = jsonContent(
createMessageObjectSchema('Forbidden'),
'Insufficient permissions'
);
const badGatewayResponse = jsonContent(
createMessageObjectSchema('YouVersion service is unavailable'),
'Upstream YouVersion failure'
);
const internalErrorResponse = jsonContent(
createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR),
'Internal server error'
);

// ─── GET /youversion/bibles ───────────────────────────────────────────────────

const getBiblesRoute = createRoute({
tags: ['YouVersion'],
method: 'get',
path: '/youversion/bibles',
middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_VIEW)] as const,
request: {
query: biblesQuerySchema,
},
responses: {
[HttpStatusCodes.OK]: jsonContent(
z.array(youVersionBibleSchema),
'List of YouVersion Bibles for the requested language'
),
[HttpStatusCodes.BAD_REQUEST]: badRequestResponse,
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedResponse,
[HttpStatusCodes.FORBIDDEN]: forbiddenResponse,
[HttpStatusCodes.BAD_GATEWAY]: badGatewayResponse,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalErrorResponse,
},
});

server.openapi(getBiblesRoute, async (c) => {
const { language_tag } = c.req.valid('query');
const result = await youVersionService.getBibles(language_tag);
if (!result.ok) {
return youVersionErrorResponse(c, result.error);
}
c.header('Cache-Control', 'private, max-age=300');
return c.json(result.data, HttpStatusCodes.OK);
});

// ─── GET /youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text ───────

const getChapterTextRoute = createRoute({
tags: ['YouVersion'],
method: 'get',
path: '/youversion/bibles/{bibleId}/books/{bookId}/chapters/{chapterId}/text',
middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_VIEW)] as const,
request: {
params: chapterTextParamSchema,
},
responses: {
[HttpStatusCodes.OK]: jsonContent(
youVersionChapterTextSchema,
'All verse texts for the requested chapter (server-side fan-out)'
),
[HttpStatusCodes.BAD_REQUEST]: badRequestResponse,
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedResponse,
[HttpStatusCodes.FORBIDDEN]: forbiddenResponse,
[HttpStatusCodes.BAD_GATEWAY]: badGatewayResponse,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalErrorResponse,
},
});

server.openapi(getChapterTextRoute, async (c) => {
const { bibleId, bookId, chapterId } = c.req.valid('param');
const result = await youVersionService.getChapterText(bibleId, bookId, chapterId);
if (!result.ok) {
return youVersionErrorResponse(c, result.error);
}
c.header('Cache-Control', 'private, max-age=300');
return c.json(result.data, HttpStatusCodes.OK);
});
19 changes: 19 additions & 0 deletions src/domains/youversion/youversion.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {
YouVersionBible,
YouVersionChapterText,
} from '@/lib/services/youversion/youversion.types';
import type { Result } from '@/lib/types';

import * as youVersionClient from '@/lib/services/youversion/youversion.client';

export async function getBibles(languageTag: string): Promise<Result<YouVersionBible[]>> {
return youVersionClient.getBibles(languageTag);
}

export async function getChapterText(
bibleId: number,
bookId: string,
chapterId: number
): Promise<Result<YouVersionChapterText>> {
return youVersionClient.getChapterText(bibleId, bookId, chapterId);
}
37 changes: 37 additions & 0 deletions src/domains/youversion/youversion.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { z } from '@hono/zod-openapi';

export {
youVersionBibleSchema,
youVersionBibleVerseSchema,
youVersionChapterTextSchema,
} from '@/lib/services/youversion/youversion.types';

// ─── Route-level query/param schemas ─────────────────────────────────────────

export const biblesQuerySchema = z.object({
language_tag: z
.string()
.min(1)
.openapi({ description: 'BCP-47 language tag (e.g. eng, fra)', example: 'eng' }),
});

export type BiblesQuery = z.infer<typeof biblesQuerySchema>;

export const chapterTextParamSchema = z.object({
bibleId: z.coerce
.number()
.int()
.positive()
.openapi({ description: 'YouVersion Bible ID', example: 1 }),
bookId: z
.string()
.min(1)
.openapi({ description: 'Book code matching YouVersion book ID (e.g. GEN)', example: 'GEN' }),
chapterId: z.coerce
.number()
.int()
.positive()
.openapi({ description: 'Chapter number', example: 1 }),
});

export type ChapterTextParam = z.infer<typeof chapterTextParamSchema>;
Loading
Loading