From 9e90f53cf27275a3f5d15b3bed0acbf7b81471d0 Mon Sep 17 00:00:00 2001 From: Bryson Hotopp <6694199+arinthros@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:50:27 -0500 Subject: [PATCH 1/3] fix(core): accept null address and description on organizations The organizations API sends null for an absent address (every organization sampled) and for some descriptions. OrganizationSchema only allowed those fields to be omitted, so getOrganization threw for every lookup and BibleVersionPicker never showed publisher names. Test mocks now mirror the live response shape, including null fields. YPE-6399 Co-Authored-By: Claude Opus 5.5 --- .changeset/organization-nullable-fields.md | 5 ++++ .../core/src/__tests__/MockOrganizations.ts | 29 ++++++++++++++++++- .../core/src/__tests__/organizations.test.ts | 16 ++++++++++ packages/core/src/schemas/organization.ts | 14 ++++++--- 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 .changeset/organization-nullable-fields.md diff --git a/.changeset/organization-nullable-fields.md b/.changeset/organization-nullable-fields.md new file mode 100644 index 00000000..f87d707c --- /dev/null +++ b/.changeset/organization-nullable-fields.md @@ -0,0 +1,5 @@ +--- +'@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` shows publisher names again. 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..8e668c3f 100644 --- a/packages/core/src/__tests__/organizations.test.ts +++ b/packages/core/src/__tests__/organizations.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ApiClient } from '../client'; import { OrganizationsClient } from '../organizations'; +import { mockOrganizationWithoutDescription } from './MockOrganizations'; describe('OrganizationsClient', () => { let apiClient: ApiClient; @@ -24,6 +25,21 @@ describe('OrganizationsClient', () => { expect(organization.name).toBe('The Lockman Foundation'); }); + it('accepts the null address the API sends for organizations without one', async () => { + const organization = await organizationsClient.getOrganization( + '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', + ); + expect(organization.address).toBeNull(); + }); + + it('accepts a null description', async () => { + const organization = await organizationsClient.getOrganization( + mockOrganizationWithoutDescription.id, + ); + expect(organization.name).toBe('MissionAssist'); + expect(organization.description).toBeNull(); + }); + it('should throw an error for invalid organization ID', async () => { await expect(organizationsClient.getOrganization('not-a-uuid')).rejects.toThrow( 'Organization ID must be a valid UUID', diff --git a/packages/core/src/schemas/organization.ts b/packages/core/src/schemas/organization.ts index 28bf85b6..a25030e0 100644 --- a/packages/core/src/schemas/organization.ts +++ b/packages/core/src/schemas/organization.ts @@ -42,8 +42,11 @@ 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). + */ + 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 +55,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). + */ + address: z.optional(z.nullable(OrganizationAddressSchema)), }); export type Organization = Readonly>; From 63585c1870fea115fe40ab4eaefc7d768a1578df Mon Sep 17 00:00:00 2001 From: Bryson Hotopp <6694199+arinthros@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:55:14 -0500 Subject: [PATCH 2/3] test(core): make the null organization field test flat Per docs/testing.md: one top-level workflow test with its own client factory instead of two cases nested under beforeEach. Co-Authored-By: Claude Opus 5.5 --- .../core/src/__tests__/organizations.test.ts | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/core/src/__tests__/organizations.test.ts b/packages/core/src/__tests__/organizations.test.ts index 8e668c3f..03fdd8a0 100644 --- a/packages/core/src/__tests__/organizations.test.ts +++ b/packages/core/src/__tests__/organizations.test.ts @@ -1,7 +1,17 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ApiClient } from '../client'; import { OrganizationsClient } from '../organizations'; -import { mockOrganizationWithoutDescription } from './MockOrganizations'; +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; @@ -25,21 +35,6 @@ describe('OrganizationsClient', () => { expect(organization.name).toBe('The Lockman Foundation'); }); - it('accepts the null address the API sends for organizations without one', async () => { - const organization = await organizationsClient.getOrganization( - '798d8fa4-f640-4155-8cfb-fa91d1d8a06c', - ); - expect(organization.address).toBeNull(); - }); - - it('accepts a null description', async () => { - const organization = await organizationsClient.getOrganization( - mockOrganizationWithoutDescription.id, - ); - expect(organization.name).toBe('MissionAssist'); - expect(organization.description).toBeNull(); - }); - it('should throw an error for invalid organization ID', async () => { await expect(organizationsClient.getOrganization('not-a-uuid')).rejects.toThrow( 'Organization ID must be a valid UUID', @@ -47,3 +42,19 @@ 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(); +}); From 0a9434ebc74029630faa1e2ed556a275b87624b9 Mon Sep 17 00:00:00 2001 From: Bryson Hotopp <6694199+arinthros@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:04:09 -0600 Subject: [PATCH 3/3] test: cover null organization fields through the version picker - UI organization mocks now match the live API shape, with null address, description, email, phone, and parent_organization_id - Add a PublisherNames story that checks the picker shows "Biblica" - Add a schema test that only address and description accept null - Changeset notes that the Organization types now include null - Schema comments link the spec follow-up, YPE-6438 Co-Authored-By: Claude Opus 5.5 --- .changeset/organization-nullable-fields.md | 4 ++- .../core/src/__tests__/organizations.test.ts | 9 ++++++ packages/core/src/schemas/organization.ts | 7 +++-- .../bible-version-picker.stories.tsx | 25 +++++++++++++++++ .../ui/src/test/mock-data/organizations.json | 28 ++++++++++++++++--- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/.changeset/organization-nullable-fields.md b/.changeset/organization-nullable-fields.md index f87d707c..99b3ce8b 100644 --- a/.changeset/organization-nullable-fields.md +++ b/.changeset/organization-nullable-fields.md @@ -2,4 +2,6 @@ '@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` shows publisher names again. +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__/organizations.test.ts b/packages/core/src/__tests__/organizations.test.ts index 03fdd8a0..424a4669 100644 --- a/packages/core/src/__tests__/organizations.test.ts +++ b/packages/core/src/__tests__/organizations.test.ts @@ -1,6 +1,7 @@ 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 { @@ -58,3 +59,11 @@ it('parses organizations whose address and description arrive as null', async () 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 a25030e0..99b2c183 100644 --- a/packages/core/src/schemas/organization.ts +++ b/packages/core/src/schemas/organization.ts @@ -44,7 +44,8 @@ export const OrganizationSchema = z.object({ name: 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). + * 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. */ @@ -56,8 +57,8 @@ export const OrganizationSchema = z.object({ /** The web site for the organization. */ website_url: z.optional(z.string()), /** - * The organization's address. The API sends null when there is none (the OpenAPI spec - * does not mark it nullable). + * 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)), }); 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 } }