fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
/**
|
|
* Tier-B · source-sync (B4) — sync frequency + manual sync.
|
|
*
|
|
* Two endpoints:
|
|
*
|
|
* - POST /api/manage_sync writes `sources.sync_frequency` (never,
|
|
* daily, weekly, monthly).
|
|
* - POST /api/sync_source kicks off a Celery `sync_source` task if
|
|
* the source is syncable (has remote_data
|
|
* and is NOT a connector).
|
|
*
|
|
* Notable contract:
|
|
* - File-based sources (no `remote_data`) cannot be manually synced —
|
|
* they return 400 "Source is not syncable". This contradicts the
|
|
* subagent brief, which assumed file sources accepted sync_source.
|
|
* Documenting the correction here; the test asserts the observed 400.
|
|
* - Connector sources (`type` starts with "connector") are rejected
|
|
* with a message pointing to /api/connectors/sync. This is a
|
|
* cleaner affordance than silently queuing a bad Celery job.
|
|
*/
|
|
|
|
import * as playwright from '@playwright/test';
|
|
const { expect, test } = playwright;
|
|
|
|
import { authedRequest } from '../../helpers/api.js';
|
|
import { newUserContext } from '../../helpers/auth.js';
|
|
import { pg } from '../../helpers/db.js';
|
|
import { resetDb } from '../../helpers/reset.js';
|
|
import { seedSource } from '../../helpers/uploads.js';
|
|
|
|
async function readSyncFrequency(sourceId: string): Promise<string | null> {
|
|
const { rows } = await pg.query<{ sync_frequency: string | null }>(
|
|
'SELECT sync_frequency FROM sources WHERE id = CAST($1 AS uuid)',
|
|
[sourceId],
|
|
);
|
|
return rows[0]?.sync_frequency ?? null;
|
|
}
|
|
|
|
test.describe('tier-b · source-sync', () => {
|
|
test.beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
test('POST /api/manage_sync writes each valid frequency (never/daily/weekly/monthly) back to the row', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const sourceId = await seedSource(sub, { name: 'sync-target' });
|
|
|
|
for (const frequency of ['daily', 'weekly', 'monthly', 'never']) {
|
|
const res = await api.post('/api/manage_sync', {
|
|
data: { source_id: sourceId, sync_frequency: frequency },
|
|
});
|
|
expect(
|
|
res.status(),
|
|
`manage_sync(${frequency}) failed ${res.status()}: ${await res.text()}`,
|
|
).toBe(200);
|
|
const body = (await res.json()) as { success: boolean };
|
|
expect(body.success).toBe(true);
|
|
|
|
const stored = await readSyncFrequency(sourceId);
|
|
expect(stored).toBe(frequency);
|
|
}
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/manage_sync with an invalid frequency → 400 and row unchanged', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const sourceId = await seedSource(sub, {
|
|
name: 'sync-target-invalid',
|
|
syncFrequency: 'weekly',
|
|
});
|
|
|
|
const res = await api.post('/api/manage_sync', {
|
|
data: { source_id: sourceId, sync_frequency: 'every-solstice' },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
|
|
// `weekly` must be untouched — an invalid frequency should never
|
|
// propagate to the sources row.
|
|
expect(await readSyncFrequency(sourceId)).toBe('weekly');
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/sync_source on a remote-backed source returns a task_id', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
// `sync_source` requires `remote_data` to be present AND the source
|
|
// type to NOT start with "connector". Anything else returns 400
|
|
// (see sources/routes.py:317-332).
|
|
const sourceId = await seedSource(sub, {
|
|
name: 'remote-url-src',
|
|
type: 'url',
|
|
remoteData: { url: 'http://127.0.0.1:7099/api/config' },
|
|
syncFrequency: 'daily',
|
|
});
|
|
|
|
const res = await api.post('/api/sync_source', {
|
|
data: { source_id: sourceId },
|
|
});
|
|
expect(
|
|
res.status(),
|
|
`sync_source failed ${res.status()}: ${await res.text()}`,
|
|
).toBe(200);
|
|
const body = (await res.json()) as { success: boolean; task_id: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.task_id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/sync_source on a connector source → 4xx and points the caller at /api/connectors/sync', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
const sourceId = await seedSource(sub, {
|
|
name: 'connector-src',
|
|
type: 'connector:google_drive',
|
|
remoteData: { provider: 'google_drive' },
|
|
});
|
|
|
|
const res = await api.post('/api/sync_source', {
|
|
data: { source_id: sourceId },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
const body = (await res.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(false);
|
|
expect(body.message.toLowerCase()).toContain('/api/connectors/sync');
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
test('POST /api/sync_source on a file-only source → 400 "not syncable" (no remote_data)', async ({
|
|
browser,
|
|
}) => {
|
|
const { context, sub, token } = await newUserContext(browser);
|
|
const api = await authedRequest(playwright, token);
|
|
try {
|
|
// File-based sources (type=local, no remote_data) are rejected as
|
|
// unsyncable — this documents the backend's intended affordance,
|
|
// not a regression. The subagent brief guessed wrong here; this
|
|
// test pins the actual contract.
|
|
const sourceId = await seedSource(sub, {
|
|
name: 'file-only-src',
|
|
type: 'local',
|
|
filePath: '.e2e-tmp/inputs/file-only-src',
|
|
});
|
|
|
|
const res = await api.post('/api/sync_source', {
|
|
data: { source_id: sourceId },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
const body = (await res.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(false);
|
|
expect(body.message.toLowerCase()).toContain('not syncable');
|
|
} finally {
|
|
await api.dispose();
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|