Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { ok } from '@/lib/types';

import {
getPrepareOfflineManifest,
getTranslationImages,
getTranslationNotes,
getTranslationQuestions,
} from './translation-resources.service';
import { imageSearchHit } from './translation-resources.test-fixtures';
import {
prepareOfflineManifestResponseSchema,
translationImagesResponseSchema,
translationNotesResponseSchema,
translationQuestionsResponseSchema,
} from './translation-resources.types';
Expand Down Expand Up @@ -75,6 +78,113 @@ describe('resource license attribution', () => {
}
);

it('carries licenseInfo through images — CC BY-SA attribution obligation; do not drop', async () => {
const imageHit = imageSearchHit(hit.id);
// Mock Aquifer image details (not a live payload).
const imageDetail = aquiferResourceDetailsSchema.parse({
...detail,
mediaType: 'Image',
content: { url: 'https://cdn.example/mock-image.jpg', size: 42 },
grouping: {
type: 'Images',
name: 'Images',
mediaType: 'Image',
licenseInfo,
collectionCode: 'Images',
},
});
vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit]));
vi.mocked(getResource).mockResolvedValue(ok(imageDetail));

const result = await getTranslationImages({
bookCode: 'GEN',
chapter: 3,
verse: 1,
languageCode: 'eng',
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Image hydration failed');
expect(translationImagesResponseSchema.parse(result.data).items[0]?.licenseInfo).toEqual(
licenseInfo
);
});

it('passes through mock caption and attribution on images when Aquifer content has them (#273 / mobile #594)', async () => {
const imageHit = imageSearchHit(hit.id);
// Mock Aquifer image details only — not live payload proof.
const imageDetail = aquiferResourceDetailsSchema.parse({
...detail,
content: {
url: 'https://cdn.example/mock-image.jpg',
thumbnailUrl: 'https://cdn.example/mock-thumb.jpg',
caption: 'Mock map caption',
attribution: 'Mock explicit credit',
size: 11,
},
grouping: {
type: 'Images',
name: 'Images',
mediaType: 'Image',
licenseInfo,
collectionCode: 'Images',
},
});
vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit]));
vi.mocked(getResource).mockResolvedValue(ok(imageDetail));

const result = await getTranslationImages({
bookCode: 'GEN',
chapter: 3,
verse: 1,
languageCode: 'eng',
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Image hydration failed');
const item = translationImagesResponseSchema.parse(result.data).items[0];
expect(item).toMatchObject({
url: 'https://cdn.example/mock-image.jpg',
thumbnailUrl: 'https://cdn.example/mock-thumb.jpg',
caption: 'Mock map caption',
attribution: 'Mock explicit credit',
licenseInfo,
size: 11,
});
});

it('omits caption, attribution, and licenseInfo on images when Aquifer mock omits them (#273 / mobile #594)', async () => {
const imageHit = imageSearchHit(hit.id);
const imageDetail = aquiferResourceDetailsSchema.parse({
...detail,
content: { url: 'https://cdn.example/mock-image.jpg', size: 3 },
grouping: {
type: 'Images',
name: 'Images',
mediaType: 'Image',
collectionCode: 'Images',
},
});
vi.mocked(searchAllResources).mockResolvedValue(ok([imageHit]));
vi.mocked(getResource).mockResolvedValue(ok(imageDetail));

const result = await getTranslationImages({
bookCode: 'GEN',
chapter: 3,
verse: 1,
languageCode: 'eng',
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Image hydration failed');
const item = translationImagesResponseSchema.parse(result.data).items[0];
expect(item).toMatchObject({
url: 'https://cdn.example/mock-image.jpg',
size: 3,
});
expect(item).not.toHaveProperty('caption');
expect(item).not.toHaveProperty('attribution');
expect(item).not.toHaveProperty('licenseInfo');
expect(item).not.toHaveProperty('thumbnailUrl');
});

it.each([true, false])(
'carries licenseInfo through the manifest (includeContent=%s) — CC BY-SA attribution obligation; do not drop',
async (includeContent) => {
Expand Down
27 changes: 15 additions & 12 deletions src/domains/translation-resources/translation-resources.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,9 @@ export const AQUIFER_RESOURCE_COLLECTIONS = {
translationQuestions: 'UWTranslationQuestions',
} as const;

/** Parallel getResource calls when hydrating search hits. */
const HYDRATE_CONCURRENCY = 5;
/** Max hydrated items kept per MANIFEST_SEARCH_CONFIGS entry. */
const MAX_MANIFEST_ITEMS_PER_CONFIG = 100;
/**
* Aquifer end-verse window for chapter-range searches. Chapters never exceed
* this verse count; mirrors fluent-mobile Prepare Offline (1–200).
*/
const HYDRATE_CONCURRENCY = 5; // parallel getResource hydrate
const MAX_MANIFEST_ITEMS_PER_CONFIG = 100; // per MANIFEST_SEARCH_CONFIGS entry
/** Aquifer chapter end-verse window (mobile Prepare Offline 1–200). */
const CHAPTER_RANGE_END_VERSE = 200;

interface ResourceSearchConfig {
Expand Down Expand Up @@ -120,26 +115,29 @@ interface ExtractedAquiferAsset {
url: string;
size?: number;
thumbnailUrl?: string;
caption?: string;
attribution?: string;
}

function ownString(record: Record<string, unknown>, fieldName: string): string | undefined {
const value = record[fieldName];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

/**
* Read url/href, size, and thumbnailUrl from a single Aquifer asset record.
* Nested sibling records must not contribute mixed fields.
*/
/** Own-record url/href + optional media metadata; do not mix nested siblings. */
function readAssetFromRecord(record: Record<string, unknown>): ExtractedAquiferAsset | undefined {
const url = ownString(record, 'url') ?? ownString(record, 'href');
if (!url) return undefined;
const size = typeof record.size === 'number' ? record.size : undefined;
const thumbnailUrl = ownString(record, 'thumbnailUrl');
const caption = ownString(record, 'caption');
const attribution = ownString(record, 'attribution');
return {
url,
...(size !== undefined ? { size } : {}),
...(thumbnailUrl ? { thumbnailUrl } : {}),
...(caption ? { caption } : {}),
...(attribution ? { attribution } : {}),
};
}

Expand Down Expand Up @@ -356,7 +354,12 @@ export async function getTranslationImagesForRange(
localizedName: hit.localizedName || hit.name,
url: asset.url,
...(asset.thumbnailUrl ? { thumbnailUrl: asset.thumbnailUrl } : {}),
...(asset.caption ? { caption: asset.caption } : {}),
...(asset.attribution ? { attribution: asset.attribution } : {}),
size: asset.size,
...(details.grouping.licenseInfo !== undefined
? { licenseInfo: details.grouping.licenseInfo }
: {}),
});
}
return ok({ items });
Expand Down
16 changes: 15 additions & 1 deletion src/domains/translation-resources/translation-resources.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,22 @@ export const translationImageItemSchema = z
title: z.string(),
localizedName: z.string(),
url: z.string(),
thumbnailUrl: z.string().optional(),
thumbnailUrl: z
.string()
.optional()
.openapi({ description: 'Optional Aquifer content.thumbnailUrl when present (non-empty)' }),
caption: z.string().optional().openapi({
description:
'Optional Aquifer content.caption string when present; omitted when absent (no fabricated caption)',
}),
attribution: z.string().optional().openapi({
description:
'Optional Aquifer content.attribution string when present; clients may also derive credit from licenseInfo',
}),
size: z.number().int().nonnegative().optional(),
licenseInfo: aquiferLicenseInfoWireSchema.optional().openapi({
description: 'Aquifer grouping.licenseInfo when present (same wire as TN/TQ)',
}),
})
.openapi('TranslationImageItem');

Expand Down
Loading