Add CI checks for /api-doc/ endpoint (#13210)

* Add CI checks for /api-doc/ endpoint

Ref: https://github.com/inventree/InvenTree/issues/13200

* fix playwright test

* Reduce flakiness
This commit is contained in:
Oliver authored and GitHub committed 2026-11-08 17:55:12 +11:00
1 parent 3866f7dbf5
commit df2a5d853e
3 files changed
+110 -12

No files matched your search

+45 -1
View File
@@ -1,6 +1,7 @@
"""Unit tests for the main web views."""
import os
import re
from django.test import override_settings
from django.urls import reverse
@@ -18,12 +19,55 @@ class ViewTests(InvenTreeTestCase):
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
)
def test_api_doc(self):
"""Test that the api-doc view works."""
"""Test that the api-doc view renders a usable Redoc page.
A 200 response alone is not sufficient - the page is a thin HTML shell
which loads Redoc from a CDN and points it at the schema endpoint.
Ref: https://github.com/inventree/InvenTree/issues/13200
"""
api_url = os.path.join(reverse('index'), 'api-doc') + '/'
response = self.client.get(api_url)
self.assertEqual(response.status_code, 200)
html = response.content.decode()
schema_url = reverse('schema')
# The page must point Redoc at our schema endpoint
self.assertIn(schema_url, html)
# Find the Redoc bundle which is loaded by the page
scripts = re.findall(r'<script([^>]*)\ssrc="([^"]+)"', html)
redoc_scripts = [(attrs, src) for attrs, src in scripts if 'redoc' in src]
self.assertEqual(len(redoc_scripts), 1, 'Redoc script not found in api-doc')
attrs, src = redoc_scripts[0]
# The Redoc version must be pinned - '@latest' can (and did) change under us
match = re.search(r'redoc@(\d+)(?:\.\d+)*/', src)
self.assertIsNotNone(
match, f'Redoc distribution must be pinned to a version: {src}'
)
# Redoc >= 3 is ES-module only and cannot be loaded via a classic <script>
if int(match.group(1)) >= 3:
self.assertIn(
'type="module"',
attrs,
f'Redoc {match.group(1)}.x must be loaded as an ES module: {src}',
)
# The schema which Redoc loads must contain generated API data
response = self.client.get(schema_url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
schema = response.json()
self.assertTrue(schema['openapi'].startswith('3.'))
self.assertEqual(schema['info']['title'], 'InvenTree API')
self.assertIn('/api/part/', schema['paths'])
self.assertIn('/api/stock/', schema['paths'])
self.assertGreater(len(schema['paths']), 100)
@override_settings(
SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver']
)
+26 -1
View File
@@ -1,4 +1,4 @@
import { type Page, expect } from '@playwright/test';
import { type Locator, type Page, expect } from '@playwright/test';
import { createApi } from './api';
export const clickOnParamFilter = async (page: Page, name: string) => {
@@ -117,6 +117,31 @@ export const navigate = async (
await page.goto(path, { waitUntil: options?.waitUntil ?? 'load' });
};
/**
* Ctrl+click a link, and return the new tab which it opens (in the same browser context).
*
* A modified click occasionally fails to open a new tab in CI - so the
* click is retried (with a short per-attempt timeout) rather than
* waiting for the entire test timeout on a single attempt.
*/
export const openInNewTab = async (
page: Page,
link: Locator,
url: string | RegExp
): Promise<Page> => {
const context = page.context();
let popup: Page | undefined;
await expect(async () => {
const popupPromise = context.waitForEvent('page', { timeout: 5000 });
await link.click({ modifiers: ['ControlOrMeta'], timeout: 5000 });
popup = await popupPromise;
}).toPass({ timeout: 30000 });
await popup!.waitForURL(url);
return popup!;
};
/**
* CLick on the 'tab' element with the provided name
*/
+39 -10
View File
@@ -1,6 +1,6 @@
import { expect, test } from './baseFixtures.js';
import { adminuser, stevenuser } from './defaults.js';
import { globalSearch } from './helpers.js';
import { adminuser, apiUrl, stevenuser } from './defaults.js';
import { globalSearch, openInNewTab } from './helpers.js';
import { doCachedLogin } from './login.js';
/**
@@ -45,10 +45,7 @@ test('General - Search', async ({ browser }) => {
.first();
await expect(customerResult).toHaveAttribute('href', /\/web\/company\/\d+/);
const popupPromise = page.context().waitForEvent('page');
await customerResult.click({ modifiers: ['Control'] });
const popup = await popupPromise;
await popup.waitForURL('**/web/company/**');
const popup = await openInNewTab(page, customerResult, '**/web/company/**');
await popup.close();
// Click through to the "Customer" results
@@ -60,10 +57,9 @@ test('General - Search', async ({ browser }) => {
/\/web\/sales\/index\/customers\?search=another\+customer/
);
const overviewPopupPromise = page.context().waitForEvent('page');
await viewAllCustomers.click({ modifiers: ['Control'] });
const overviewPopup = await overviewPopupPromise;
await overviewPopup.waitForURL(
const overviewPopup = await openInNewTab(
page,
viewAllCustomers,
'**/web/sales/index/customers?search=another+customer'
);
await overviewPopup.close();
@@ -92,3 +88,36 @@ test('General - Search', async ({ browser }) => {
await page.waitForURL('**/purchasing/index/manufacturer-parts**');
await page.getByRole('cell', { name: 'RT0402BRD07100KL' }).waitFor();
});
/**
* Test that the API documentation page actually renders.
*
* The /api-doc/ view is a thin HTML shell which loads Redoc from a CDN;
* a 200 response does not mean that the documentation is displayed.
* Ref: https://github.com/inventree/InvenTree/issues/13200
*/
test('General - API Documentation', async ({ browser }) => {
const page = await doCachedLogin(browser, {
user: stevenuser
});
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
// Note: api-doc page is not forwarded to port 5173
const docUrl = new URL('http://localhost:8000/api-doc/', apiUrl).toString();
const response = await page.goto(docUrl);
await page.waitForLoadState('networkidle');
expect(response?.status()).toBe(200);
// Redoc renders the schema title and the generated API endpoints
await page
.getByRole('heading', { name: /InvenTree API/ })
.first()
.waitFor({ timeout: 30000 });
await page.getByText('Download OpenAPI specification').first().waitFor();
await page.getByText('/api/part/', { exact: false }).first().waitFor();
expect(errors).toEqual([]);
});