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
7 changes: 7 additions & 0 deletions .changeset/organization-nullable-fields.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 28 additions & 1 deletion packages/core/src/__tests__/MockOrganizations.ts
Original file line number Diff line number Diff line change
@@ -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,
];
36 changes: 36 additions & 0 deletions packages/core/src/__tests__/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
});
Comment thread
camrun91 marked this conversation as resolved.
Resolved

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);
});
15 changes: 11 additions & 4 deletions packages/core/src/schemas/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<z.infer<typeof OrganizationSchema>>;
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/components/bible-version-picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 24 additions & 4 deletions packages/ui/src/test/mock-data/organizations.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading