From a90676d7ac9ce2a2a6983314dd406b83253914cc Mon Sep 17 00:00:00 2001 From: veeck Date: Fri, 14 Aug 2026 19:03:35 +0200 Subject: [PATCH] Add native rendering support for the "project" WordPress post type Links to /project/{slug} (e.g. https://volksverpetzer.de/project/orgakarten) previously always fell back to the embedded webview because WordPressAPI only ever queried the "posts" REST base, which never matches a custom post type. getPost() now accepts a postType override, [category]/[slug].tsx maps the "project" category to it, and convertLoadProps defaults categories to [] since custom post types aren't necessarily registered with that taxonomy. Known limitation: the live WordPress REST API currently returns 401 for /wp-json/wp/v2/project (only "posts" and "media" are publicly allowlisted by a security plugin), so this degrades to the same webview fallback as before until that's addressed server-side. Follow-up tracked separately. Co-Authored-By: Claude Sonnet 5 --- __tests__/app/category/LoadArticle.test.tsx | 99 ++++++++++++++++++++- __tests__/helpers/WordPressAPI.test.ts | 34 +++++++ src/app/[category]/[slug].tsx | 30 +++++-- src/helpers/network/WordPressAPI.ts | 11 ++- 4 files changed, 164 insertions(+), 10 deletions(-) diff --git a/__tests__/app/category/LoadArticle.test.tsx b/__tests__/app/category/LoadArticle.test.tsx index a62d8e29..49c012b4 100644 --- a/__tests__/app/category/LoadArticle.test.tsx +++ b/__tests__/app/category/LoadArticle.test.tsx @@ -35,6 +35,14 @@ jest.mock("#/helpers/network/WordPressAPI", () => ({ default: { getPost: jest.fn(() => Promise.resolve(null)), create: jest.fn(() => null), + convertLoadProps: jest.fn((data) => data), + getFeatureImage: jest.fn(() => + Promise.resolve({ + image: undefined, + thumb: undefined, + credit: undefined, + }), + ), }, })); @@ -110,8 +118,9 @@ describe("LoadArticle article fallback (slug not found)", () => { it("keeps the deep-link anchor on the fallback URL so the webview jumps to it", async () => { const { useLocalSearchParams } = jest.requireMock("expo-router"); - // Custom post types (e.g. /project/…) are not served by the posts API, - // so anchored deep links to them always land on this fallback. + // The mocked API still resolves no post here, so this exercises the same + // not-found fallback as any other category — see the "project post type" + // describe block below for the mapping itself. useLocalSearchParams.mockReturnValue({ category: "project", slug: "10fakten", @@ -158,6 +167,92 @@ describe("LoadArticle article fallback (slug not found)", () => { }); }); +describe("LoadArticle project post type", () => { + beforeEach(() => jest.clearAllMocks()); + + it("queries the 'project' REST base instead of 'posts' for the project category", async () => { + const WordPressAPI = jest.requireMock( + "#/helpers/network/WordPressAPI", + ).default; + const { useLocalSearchParams } = jest.requireMock("expo-router"); + useLocalSearchParams.mockReturnValue({ + category: "project", + slug: "orgakarten", + }); + + await render(); + + await waitFor(() => expect(WordPressAPI.getPost).toHaveBeenCalled()); + expect(WordPressAPI.getPost).toHaveBeenCalledWith( + "orgakarten", + expect.anything(), + "project", + ); + }); + + it("queries the default 'posts' REST base for a regular category", async () => { + const WordPressAPI = jest.requireMock( + "#/helpers/network/WordPressAPI", + ).default; + const { useLocalSearchParams } = jest.requireMock("expo-router"); + useLocalSearchParams.mockReturnValue({ + category: "faktencheck", + slug: "some-article", + }); + + await render(); + + await waitFor(() => expect(WordPressAPI.getPost).toHaveBeenCalled()); + expect(WordPressAPI.getPost).toHaveBeenCalledWith( + "some-article", + expect.anything(), + "posts", + ); + }); + + it("renders natively on success, without fetching a feature image when the post has none", async () => { + const WordPressAPI = jest.requireMock( + "#/helpers/network/WordPressAPI", + ).default; + // "project" entries aren't guaranteed to set a featured image, so + // "wp:featuredmedia" may be entirely absent from the response. + const projectPost = { + id: 1, + date: "2024-01-01T00:00:00", + date_gmt: "2024-01-01T00:00:00", + link: "https://volksverpetzer.de/project/orgakarten/", + slug: "orgakarten", + title: { rendered: "Orgakarten" }, + content: { rendered: "

Hi

" }, + _links: {}, + }; + WordPressAPI.getPost.mockResolvedValueOnce(projectPost); + WordPressAPI.convertLoadProps.mockImplementationOnce((data: any) => ({ + ...data, + title: data.title.rendered, + description: "", + authors: [], + categories: [], + })); + + const { useLocalSearchParams } = jest.requireMock("expo-router"); + useLocalSearchParams.mockReturnValue({ + category: "project", + slug: "orgakarten", + }); + + await render(); + + await waitFor(() => { + const Article = jest.requireMock( + "#/screens/Home/components/article/Article", + ); + expect(Article).toHaveBeenCalled(); + }); + expect(WordPressAPI.getFeatureImage).not.toHaveBeenCalled(); + }); +}); + describe("LoadArticle native article anchor", () => { beforeEach(() => jest.clearAllMocks()); diff --git a/__tests__/helpers/WordPressAPI.test.ts b/__tests__/helpers/WordPressAPI.test.ts index 17a10222..90fd6545 100644 --- a/__tests__/helpers/WordPressAPI.test.ts +++ b/__tests__/helpers/WordPressAPI.test.ts @@ -101,6 +101,22 @@ describe("WordPressAPI", () => { expect(result).toBeUndefined(); spy.mockRestore(); }); + + it("queries a custom post type's own REST base when given", async () => { + const spy = jest.spyOn(Networking, "get").mockResolvedValue([] as any); + await WordPressAPI.getPost("orgakarten", undefined, "project"); + expect(spy).toHaveBeenCalledWith( + WordPressAPI["client"], + `/wp-json/wp/v2/project`, + { + params: { + slug: "orgakarten", + _embed: "author", + }, + }, + ); + spy.mockRestore(); + }); }); describe("getFeatureImage", () => { @@ -378,5 +394,23 @@ describe("WordPressAPI", () => { } as any); expect(article.reading_time).toBeUndefined(); }); + + it("preserves categories when present in raw data", () => { + const article = WordPressAPI.convertLoadProps({ + ...baseData, + categories: [123], + } as any); + expect(article.categories).toEqual([123]); + }); + + it("defaults categories to an empty array when absent from raw data", () => { + // Custom post types (e.g. "project") aren't necessarily registered + // with the category taxonomy, so the field may be missing entirely. + const { categories: _categories, ...dataWithoutCategories } = baseData; + const article = WordPressAPI.convertLoadProps({ + ...dataWithoutCategories, + } as any); + expect(article.categories).toEqual([]); + }); }); }); diff --git a/src/app/[category]/[slug].tsx b/src/app/[category]/[slug].tsx index ec67d18b..7a1021e9 100644 --- a/src/app/[category]/[slug].tsx +++ b/src/app/[category]/[slug].tsx @@ -20,6 +20,13 @@ type LoadArticleParameters = { "#"?: string; }; +// WordPress post types whose REST base differs from the default "posts" — +// for these, the URL's category segment is the post type's own rewrite +// slug rather than a category taxonomy term. +const CUSTOM_POST_TYPES: Record = { + project: "project", +}; + /** * Loads an article based on the provided slug. */ @@ -73,9 +80,16 @@ const LoadArticle = () => { return; } + // Secondary WP sites (e.g. Prüfpunkt) are only ever fed the "posts" + // type today, so the custom-post-type mapping only applies to the + // primary site's lookup. const _article = secondaryApi ? await secondaryApi.getPost(slug, signal) - : await WordPressAPI.getPost(slug, signal); + : await WordPressAPI.getPost( + slug, + signal, + CUSTOM_POST_TYPES[category ?? ""] ?? "posts", + ); if (signal.aborted) return; // No post for this slug — fall back to the webview instead of letting // convertLoadProps throw on undefined for control flow. @@ -87,10 +101,14 @@ const LoadArticle = () => { const loadedArticle: ArticleProperties = WordPressAPI.convertLoadProps(_article); - const { image, credit } = await WordPressAPI.getFeatureImage( - loadedArticle._links["wp:featuredmedia"][0].href, - signal, - ); + // Not every post type supports a featured image (e.g. "project" + // entries may not set one), so there's no "wp:featuredmedia" link + // to follow in that case. + const featuredMediaHref = + loadedArticle._links["wp:featuredmedia"]?.[0]?.href; + const { image, credit } = featuredMediaHref + ? await WordPressAPI.getFeatureImage(featuredMediaHref, signal) + : { image: undefined, credit: undefined }; if (signal.aborted) return; setArticle(loadedArticle); @@ -104,7 +122,7 @@ const LoadArticle = () => { setIsLoading(false); } }, - [slug, secondaryApi, originalUrl, wpUrl], + [slug, category, secondaryApi, originalUrl, wpUrl], ); useEffect(() => { diff --git a/src/helpers/network/WordPressAPI.ts b/src/helpers/network/WordPressAPI.ts index b879790c..524bf512 100644 --- a/src/helpers/network/WordPressAPI.ts +++ b/src/helpers/network/WordPressAPI.ts @@ -72,14 +72,18 @@ export default class WordPressAPI { /** * Get a single post by slug. + * @param postType - REST base of the post type to query. Defaults to the + * standard "posts", but some content (e.g. the "project" post type) is + * registered under its own REST base instead. */ static async getPost( slug: string, signal?: AbortSignal, + postType: string = "posts", ): Promise { const posts = await netGet( WordPressAPI.client, - `/wp-json/wp/v2/posts`, + `/wp-json/wp/v2/${postType}`, { params: { slug, @@ -231,6 +235,9 @@ export default class WordPressAPI { display_name: a.name, slug: a.slug, })); - return { ...data, title, description, authors }; + // Custom post types (e.g. "project") aren't necessarily registered with + // the category taxonomy, so the field may be absent entirely. + const categories = data.categories ?? []; + return { ...data, title, description, authors, categories }; } }