diff --git a/.changeset/organization-nullable-fields.md b/.changeset/organization-nullable-fields.md new file mode 100644 index 00000000..99b3ce8b --- /dev/null +++ b/.changeset/organization-nullable-fields.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': patch +--- + +Fix `OrganizationsClient.getOrganization` rejecting live API responses. The API sends `address: null` for organizations without an address and `description: null` for some, but `OrganizationSchema` only allowed those fields to be omitted, so every lookup threw a validation error. Both fields now accept `null`, and `BibleVersionPicker` now shows publisher names. + +The `Organization` `description` and `address` types now include `null`; check for null when you read them. Code that passes these fields where `string | undefined` or `OrganizationAddress | undefined` is expected stops compiling under strict null checks. diff --git a/packages/core/src/__tests__/MockOrganizations.ts b/packages/core/src/__tests__/MockOrganizations.ts index ae85e9ef..eb13c90b 100644 --- a/packages/core/src/__tests__/MockOrganizations.ts +++ b/packages/core/src/__tests__/MockOrganizations.ts @@ -1,17 +1,44 @@ import type { Organization } from '../types'; +// Shapes mirror live GET /v1/organizations/{id} responses, which send null for absent +// optional fields rather than omitting them. export const mockLockmanOrganization: Organization = { id: '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', + parent_organization_id: null, name: 'The Lockman Foundation', + description: 'Publisher of the New American Standard Bible and the Amplified Bible.', + phone: null, primary_language: 'en', website_url: 'https://www.lockman.org', + address: null, }; export const mockBiblicaOrganization: Organization = { id: '05a9aa40-37b6-4e34-b9f1-a443fa4b1fff', + parent_organization_id: null, name: 'Biblica', + description: 'Publisher and translation sponsor of the New International Version.', + phone: null, primary_language: 'en', website_url: 'https://www.biblica.com', + address: null, }; -export const mockOrganizations: Organization[] = [mockLockmanOrganization, mockBiblicaOrganization]; +/** Some live organizations also send `description: null`. */ +export const mockOrganizationWithoutDescription: Organization = { + id: '3c6f0a8e-5b2d-4f1e-9a7c-2d8b4e6f1a3c', + parent_organization_id: null, + name: 'MissionAssist', + description: null, + email: null, + phone: null, + primary_language: 'en', + website_url: 'https://www.missionassist.org.uk', + address: null, +}; + +export const mockOrganizations: Organization[] = [ + mockLockmanOrganization, + mockBiblicaOrganization, + mockOrganizationWithoutDescription, +]; diff --git a/packages/core/src/__tests__/organizations.test.ts b/packages/core/src/__tests__/organizations.test.ts index de150bcb..424a4669 100644 --- a/packages/core/src/__tests__/organizations.test.ts +++ b/packages/core/src/__tests__/organizations.test.ts @@ -1,6 +1,18 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ApiClient } from '../client'; import { OrganizationsClient } from '../organizations'; +import { OrganizationSchema } from '../schemas/organization'; +import { mockLockmanOrganization, mockOrganizationWithoutDescription } from './MockOrganizations'; + +function createOrganizationsClient(): OrganizationsClient { + return new OrganizationsClient( + new ApiClient({ + apiHost: process.env.YVP_API_HOST || '', + appKey: process.env.YVP_APP_KEY || '', + installationId: 'test-installation', + }), + ); +} describe('OrganizationsClient', () => { let apiClient: ApiClient; @@ -31,3 +43,27 @@ describe('OrganizationsClient', () => { }); }); }); + +it('parses organizations whose address and description arrive as null', async () => { + const organizationsClient = createOrganizationsClient(); + + // Every live organization sends address: null + const lockman = await organizationsClient.getOrganization(mockLockmanOrganization.id); + expect(lockman.name).toBe('The Lockman Foundation'); + expect(lockman.address).toBeNull(); + + // Some also send description: null + const missionAssist = await organizationsClient.getOrganization( + mockOrganizationWithoutDescription.id, + ); + expect(missionAssist.name).toBe('MissionAssist'); + expect(missionAssist.description).toBeNull(); +}); + +it('accepts null only for address and description, keeping the other fields strict', () => { + const organization = { ...mockLockmanOrganization, address: null, description: null }; + expect(OrganizationSchema.safeParse(organization).success).toBe(true); + + expect(OrganizationSchema.safeParse({ ...organization, name: null }).success).toBe(false); + expect(OrganizationSchema.safeParse({ ...organization, website_url: null }).success).toBe(false); +}); diff --git a/packages/core/src/schemas/organization.ts b/packages/core/src/schemas/organization.ts index 28bf85b6..99b2c183 100644 --- a/packages/core/src/schemas/organization.ts +++ b/packages/core/src/schemas/organization.ts @@ -42,8 +42,12 @@ export const OrganizationSchema = z.object({ * used. The chosen language is returned in the Content-Language header. */ name: z.optional(z.string()), - /** Description of the organization: its purpose, goals, values, and mission. */ - description: z.optional(z.string()), + /** + * Description of the organization: its purpose, goals, values, and mission. + * The API sends null when there is none. The OpenAPI spec does not mark it nullable yet + * (YPE-6438). + */ + description: z.optional(z.nullable(z.string())), /** The contact email address for the organization if provided. */ email: z.optional(z.nullable(z.string())), /** The contact phone number for the organization if provided. */ @@ -52,8 +56,11 @@ export const OrganizationSchema = z.object({ primary_language: z.optional(z.string()), /** The web site for the organization. */ website_url: z.optional(z.string()), - /** The organization's address. */ - address: z.optional(OrganizationAddressSchema), + /** + * The organization's address. The API sends null when there is none. The OpenAPI spec + * does not mark it nullable yet (YPE-6438). + */ + address: z.optional(z.nullable(OrganizationAddressSchema)), }); export type Organization = Readonly>; diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 5c8218a4..3e0f4071 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -526,6 +526,31 @@ export const InteractiveVersionSearch: Story = { }, }; +export const PublisherNames: Story = { + args: { + versionId: 111, + }, + tags: ['integration'], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Open popover + const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); + await userEvent.click(trigger); + + // The organization mocks send null fields like the live API, so the publisher name + // shows only if core parses those responses + const row = await screen.findByRole( + 'listitem', + { name: /new international version 2011/i }, + { timeout: 5_000 }, + ); + await expect( + await within(row).findByText('Biblica', {}, { timeout: 5_000 }), + ).toBeInTheDocument(); + }, +}; + export const RealAPI: Story = { args: { versionId: 111, diff --git a/packages/ui/src/test/mock-data/organizations.json b/packages/ui/src/test/mock-data/organizations.json index 7a184694..4cfab1c0 100644 --- a/packages/ui/src/test/mock-data/organizations.json +++ b/packages/ui/src/test/mock-data/organizations.json @@ -1,26 +1,46 @@ { "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff": { "id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff", + "parent_organization_id": null, "name": "Biblica", + "description": "Publisher and translation sponsor of the New International Version.", + "email": null, + "phone": null, "primary_language": "en", - "website_url": "https://www.biblica.com" + "website_url": "https://www.biblica.com", + "address": null }, "798d8fa4-f640-4155-8cfb-fa91d1d8a06c": { "id": "798d8fa4-f640-4155-8cfb-fa91d1d8a06c", + "parent_organization_id": null, "name": "The Lockman Foundation", + "description": "Publisher of the New American Standard Bible and the Amplified Bible.", + "email": null, + "phone": null, "primary_language": "en", - "website_url": "https://www.lockman.org" + "website_url": "https://www.lockman.org", + "address": null }, "f2ac19b4-768c-4564-acaf-f28724235ad0": { "id": "f2ac19b4-768c-4564-acaf-f28724235ad0", + "parent_organization_id": null, "name": "Artists for Israel International", + "description": "Publisher of the Tree of Life Version.", + "email": null, + "phone": null, "primary_language": "en", - "website_url": "https://www.afii.org" + "website_url": "https://www.afii.org", + "address": null }, "f819451a-bc31-4037-b4bd-0c4aa00fe0f6": { "id": "f819451a-bc31-4037-b4bd-0c4aa00fe0f6", + "parent_organization_id": null, "name": "Cambridge University Press", + "description": null, + "email": null, + "phone": null, "primary_language": "en", - "website_url": "https://www.cambridge.org/bibles" + "website_url": "https://www.cambridge.org/bibles", + "address": null } }