chore: add integration tests for import/save route regression coverage

This commit is contained in:
GitHub Actions
2026-03-02 14:52:45 +00:00
parent b5c5ab0bc3
commit 077e3c1d2b
6 changed files with 674 additions and 2 deletions

View File

@@ -78,11 +78,20 @@ describe('import API', () => {
mockedDelete.mockResolvedValue({});
await cancelImport(sessionUUID);
expect(client.delete).toHaveBeenCalledTimes(1);
expect(client.delete).toHaveBeenCalledWith('/import/cancel', {
params: {
session_uuid: sessionUUID,
},
});
const [, requestConfig] = mockedDelete.mock.calls[0];
expect(requestConfig).toEqual({
params: {
session_uuid: sessionUUID,
},
});
});
it('forwards commitImport errors', async () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cancelJSONImport } from '../jsonImport';
import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport';
import client from '../client';
vi.mock('../client', () => ({
@@ -26,6 +26,67 @@ describe('jsonImport API', () => {
});
});
it('uploadJSONExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'json-session-456',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadJSONExport(content);
expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'json-session-789';
const resolutions = { 'json.example.com': 'replace' };
const names = { 'json.example.com': 'JSON Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitJSONImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/json/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadJSONExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitJSONImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelJSONImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { cancelNPMImport } from '../npmImport';
import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport';
import client from '../client';
vi.mock('../client', () => ({
@@ -26,6 +26,67 @@ describe('npmImport API', () => {
});
});
it('uploadNPMExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'npm-session-456',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadNPMExport(content);
expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'npm-session-789';
const resolutions = { 'npm.example.com': 'replace' };
const names = { 'npm.example.com': 'NPM Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitNPMImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/npm/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadNPMExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitNPMImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelNPMImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);

View File

@@ -29,6 +29,86 @@ describe('useJSONImport', () => {
vi.clearAllMocks()
})
it('sets preview and sessionId after successful upload', async () => {
const uploadResponse = {
session: {
id: 'json-session-upload',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('json-session-upload')
expect(result.current.preview).toEqual(uploadResponse)
})
})
it('commits active session and clears preview/session state', async () => {
const uploadResponse = {
session: {
id: 'json-session-commit',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitResponse = {
created: 1,
updated: 0,
skipped: 0,
errors: [],
}
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitJSONImport).mockResolvedValue(commitResponse)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('json-session-commit')
})
await act(async () => {
await result.current.commit({ 'json.example.com': 'replace' }, { 'json.example.com': 'JSON Example' })
})
expect(api.commitJSONImport).toHaveBeenCalledWith(
'json-session-commit',
{ 'json.example.com': 'replace' },
{ 'json.example.com': 'JSON Example' }
)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
expect(result.current.preview).toBeNull()
expect(result.current.commitResult).toEqual(commitResponse)
})
})
it('passes active session UUID to cancelJSONImport', async () => {
const sessionId = 'json-session-123'
vi.mocked(api.uploadJSONExport).mockResolvedValue({
@@ -66,4 +146,45 @@ describe('useJSONImport', () => {
expect(result.current.sessionId).toBeNull()
})
})
it('returns No active session and skips cancel API call when session is missing', async () => {
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await expect(result.current.cancel()).rejects.toThrow('No active session')
expect(api.cancelJSONImport).not.toHaveBeenCalled()
})
it('exposes commit error and preserves session on commit failure', async () => {
const uploadResponse = {
session: {
id: 'json-session-error',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitError = new Error('404 Not Found')
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitJSONImport).mockRejectedValue(commitError)
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
await waitFor(() => {
expect(result.current.commitError).toBe(commitError)
expect(result.current.sessionId).toBe('json-session-error')
expect(result.current.preview).not.toBeNull()
})
})
})

View File

@@ -29,6 +29,86 @@ describe('useNPMImport', () => {
vi.clearAllMocks()
})
it('sets preview and sessionId after successful upload', async () => {
const uploadResponse = {
session: {
id: 'npm-session-upload',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('npm-session-upload')
expect(result.current.preview).toEqual(uploadResponse)
})
})
it('commits active session and clears preview/session state', async () => {
const uploadResponse = {
session: {
id: 'npm-session-commit',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitResponse = {
created: 1,
updated: 0,
skipped: 0,
errors: [],
}
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitNPMImport).mockResolvedValue(commitResponse)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await waitFor(() => {
expect(result.current.sessionId).toBe('npm-session-commit')
})
await act(async () => {
await result.current.commit({ 'npm.example.com': 'replace' }, { 'npm.example.com': 'NPM Example' })
})
expect(api.commitNPMImport).toHaveBeenCalledWith(
'npm-session-commit',
{ 'npm.example.com': 'replace' },
{ 'npm.example.com': 'NPM Example' }
)
await waitFor(() => {
expect(result.current.sessionId).toBeNull()
expect(result.current.preview).toBeNull()
expect(result.current.commitResult).toEqual(commitResponse)
})
})
it('passes active session UUID to cancelNPMImport', async () => {
const sessionId = 'npm-session-123'
vi.mocked(api.uploadNPMExport).mockResolvedValue({
@@ -66,4 +146,45 @@ describe('useNPMImport', () => {
expect(result.current.sessionId).toBeNull()
})
})
it('returns No active session and skips cancel API call when session is missing', async () => {
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await expect(result.current.cancel()).rejects.toThrow('No active session')
expect(api.cancelNPMImport).not.toHaveBeenCalled()
})
it('exposes commit error and preserves session on commit failure', async () => {
const uploadResponse = {
session: {
id: 'npm-session-error',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
}
const commitError = new Error('404 Not Found')
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
vi.mocked(api.commitNPMImport).mockRejectedValue(commitError)
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
await act(async () => {
await result.current.upload('{"proxy_hosts":[]}')
})
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
await waitFor(() => {
expect(result.current.commitError).toBe(commitError)
expect(result.current.sessionId).toBe('npm-session-error')
expect(result.current.preview).not.toBeNull()
})
})
})

View File

@@ -0,0 +1,299 @@
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import { getStorageStateAuthHeaders } from '../utils/api-helpers';
type SessionResponse = {
session?: {
id?: string;
};
};
const SAMPLE_CADDYFILE = `example.com {
reverse_proxy localhost:8080
}`;
const SAMPLE_NPM_OR_JSON_EXPORT = JSON.stringify(
{
proxy_hosts: [
{
domain_names: ['route-regression.example.test'],
forward_host: 'localhost',
forward_port: 8080,
forward_scheme: 'http',
},
],
access_lists: [],
certificates: [],
},
null,
2
);
function expectPredictableRouteMiss(status: number): void {
expect([404, 405]).toContain(status);
}
function expectCanonicalNon404(status: number): void {
expect(status).not.toBe(404);
expect(status).toBeLessThan(500);
}
async function readSessionId(response: import('@playwright/test').APIResponse): Promise<string> {
const data = (await response.json()) as SessionResponse;
const sessionId = data?.session?.id;
expect(sessionId).toBeTruthy();
return sessionId as string;
}
test.describe('Import/Save Route Regression Coverage', () => {
test('Caddy import flow stages use canonical routes and reject route drift', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const headers = getStorageStateAuthHeaders();
await test.step('Open Caddy import page and validate route-negative probes', async () => {
await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { level: 1 })).toContainText(/import/i);
await expect(page.getByRole('button', { name: /parse|review/i })).toBeVisible();
const wrongStatusMethod = await page.request.post('/api/v1/import/status', {
headers,
data: {},
});
expectPredictableRouteMiss(wrongStatusMethod.status());
const wrongUploadMethod = await page.request.get('/api/v1/import/upload', { headers });
expectPredictableRouteMiss(wrongUploadMethod.status());
const wrongCancelMethod = await page.request.post('/api/v1/import/cancel', {
headers,
data: {},
});
expectPredictableRouteMiss(wrongCancelMethod.status());
});
await test.step('Run canonical Caddy import status/upload/cancel path', async () => {
const statusResponse = await page.request.get('/api/v1/import/status', { headers });
expectCanonicalNon404(statusResponse.status());
const uploadForCancel = await page.request.post('/api/v1/import/upload', {
headers,
data: { content: SAMPLE_CADDYFILE },
});
expectCanonicalNon404(uploadForCancel.status());
const cancelSessionId = await readSessionId(uploadForCancel);
const cancelResponse = await page.request.delete('/api/v1/import/cancel', {
headers,
params: { session_uuid: cancelSessionId },
});
expectCanonicalNon404(cancelResponse.status());
});
await test.step('Run canonical Caddy preview/backup-before-commit/commit/post-state path', async () => {
const uploadForCommit = await page.request.post('/api/v1/import/upload', {
headers,
data: { content: SAMPLE_CADDYFILE },
});
expectCanonicalNon404(uploadForCommit.status());
const commitSessionId = await readSessionId(uploadForCommit);
const previewResponse = await page.request.get('/api/v1/import/preview', { headers });
expectCanonicalNon404(previewResponse.status());
const backupBeforeCommit = await page.request.post('/api/v1/backups', {
headers,
data: {},
});
expectCanonicalNon404(backupBeforeCommit.status());
const commitResponse = await page.request.post('/api/v1/import/commit', {
headers,
data: {
session_uuid: commitSessionId,
resolutions: {},
names: {},
},
});
expectCanonicalNon404(commitResponse.status());
const postState = await page.request.get('/api/v1/import/status', { headers });
expectCanonicalNon404(postState.status());
});
});
test('NPM and JSON import critical routes pass canonical methods and reject drift', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const headers = getStorageStateAuthHeaders();
await test.step('NPM import upload/commit/cancel with route-mismatch checks', async () => {
await page.goto('/tasks/import/npm', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading').filter({ hasText: /npm/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /upload\s*&\s*preview/i })).toBeVisible();
const npmWrongMethod = await page.request.get('/api/v1/import/npm/upload', { headers });
expectPredictableRouteMiss(npmWrongMethod.status());
const npmCancelWrongPath = await page.request.post('/api/v1/import/npm/cancel-session', {
headers,
data: {},
});
expectPredictableRouteMiss(npmCancelWrongPath.status());
const npmUploadForCancel = await page.request.post('/api/v1/import/npm/upload', {
headers,
data: { content: SAMPLE_NPM_OR_JSON_EXPORT },
});
expectCanonicalNon404(npmUploadForCancel.status());
const npmCancelSession = await readSessionId(npmUploadForCancel);
const npmCancel = await page.request.post('/api/v1/import/npm/cancel', {
headers,
data: { session_uuid: npmCancelSession },
});
expectCanonicalNon404(npmCancel.status());
const npmUploadForCommit = await page.request.post('/api/v1/import/npm/upload', {
headers,
data: { content: SAMPLE_NPM_OR_JSON_EXPORT },
});
expectCanonicalNon404(npmUploadForCommit.status());
const npmCommitSession = await readSessionId(npmUploadForCommit);
const npmCommit = await page.request.post('/api/v1/import/npm/commit', {
headers,
data: {
session_uuid: npmCommitSession,
resolutions: {},
names: {},
},
});
expectCanonicalNon404(npmCommit.status());
});
await test.step('JSON import upload/commit/cancel with route-mismatch checks', async () => {
await page.goto('/tasks/import/json', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading').filter({ hasText: /json/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /upload\s*&\s*preview/i })).toBeVisible();
const jsonWrongMethod = await page.request.get('/api/v1/import/json/upload', { headers });
expectPredictableRouteMiss(jsonWrongMethod.status());
const jsonCommitWrongPath = await page.request.post('/api/v1/import/json/commit-now', {
headers,
data: {},
});
expectPredictableRouteMiss(jsonCommitWrongPath.status());
const jsonUploadForCancel = await page.request.post('/api/v1/import/json/upload', {
headers,
data: { content: SAMPLE_NPM_OR_JSON_EXPORT },
});
expectCanonicalNon404(jsonUploadForCancel.status());
const jsonCancelSession = await readSessionId(jsonUploadForCancel);
const jsonCancel = await page.request.post('/api/v1/import/json/cancel', {
headers,
data: { session_uuid: jsonCancelSession },
});
expectCanonicalNon404(jsonCancel.status());
const jsonUploadForCommit = await page.request.post('/api/v1/import/json/upload', {
headers,
data: { content: SAMPLE_NPM_OR_JSON_EXPORT },
});
expectCanonicalNon404(jsonUploadForCommit.status());
const jsonCommitSession = await readSessionId(jsonUploadForCommit);
const jsonCommit = await page.request.post('/api/v1/import/json/commit', {
headers,
data: {
session_uuid: jsonCommitSession,
resolutions: {},
names: {},
},
});
expectCanonicalNon404(jsonCommit.status());
});
});
test('Save flow routes for settings and proxy-host paths detect 404 regressions', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const headers = getStorageStateAuthHeaders();
let createdProxyUUID = '';
await test.step('System settings save path succeeds on canonical route', async () => {
await page.goto('/settings/system', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /system settings/i })).toBeVisible();
const saveButton = page.getByRole('button', { name: /save settings/i }).first();
await expect(saveButton).toBeEnabled();
const saveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/api/v1/settings') &&
['POST', 'PATCH'].includes(response.request().method())
);
await saveButton.click();
const saveResponse = await saveResponsePromise;
expectCanonicalNon404(saveResponse.status());
const wrongSettingsMethod = await page.request.delete('/api/v1/settings', { headers });
expectPredictableRouteMiss(wrongSettingsMethod.status());
});
await test.step('Proxy-host save path succeeds on canonical route and rejects wrong method/path', async () => {
const unique = `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
const createResponse = await page.request.post('/api/v1/proxy-hosts', {
headers,
data: {
name: `PR3 Route Regression ${unique}`,
domain_names: `pr3-route-${unique}.example.test`,
forward_host: 'localhost',
forward_port: 8080,
forward_scheme: 'http',
websocket_support: false,
enabled: true,
},
});
expectCanonicalNon404(createResponse.status());
expect([200, 201]).toContain(createResponse.status());
const created = (await createResponse.json()) as { uuid?: string };
createdProxyUUID = created.uuid || '';
expect(createdProxyUUID).toBeTruthy();
const updateResponse = await page.request.put(`/api/v1/proxy-hosts/${createdProxyUUID}`, {
headers,
data: {
name: `PR3 Route Regression Updated ${unique}`,
domain_names: `pr3-route-${unique}.example.test`,
forward_host: 'localhost',
forward_port: 8081,
forward_scheme: 'http',
websocket_support: false,
enabled: true,
},
});
expectCanonicalNon404(updateResponse.status());
const wrongProxyMethod = await page.request.post(`/api/v1/proxy-hosts/${createdProxyUUID}`, {
headers,
data: {},
});
expectPredictableRouteMiss(wrongProxyMethod.status());
const wrongProxyPath = await page.request.put('/api/v1/proxy-host', {
headers,
data: {},
});
expectPredictableRouteMiss(wrongProxyPath.status());
});
if (createdProxyUUID) {
await test.step('Cleanup created proxy host', async () => {
const cleanup = await page.request.delete(`/api/v1/proxy-hosts/${createdProxyUUID}`, { headers });
expectCanonicalNon404(cleanup.status());
});
}
});
});