Files
profilarr/docs/backend/pcd-tests.md
2026-05-06 08:05:34 +09:30

8.6 KiB

PCD Tests

PCD tests cover the database-first behavior of the PCD system: writer output, op replay, conflict detection, conflict resolution, and compiled cache state. For the PCD system itself, see pcd.md. For entity behavior, see pcd-entities.md.

Table of Contents

Purpose

PCD correctness is tested below the browser because the source of truth is the compiled cache, not the UI. The suite verifies four contracts:

  • Writer calls persist the expected pcd_ops rows.
  • Compile replays schema, base, tweaks, and user layers into the expected cache state.
  • Stale user guards surface conflicts according to the database instance's conflict strategy.
  • Align and override resolution leave the compiled cache in the expected final state.

Suite Layout

PCD tests live in the integration test tree:

tests/integration/pcd/
  harness/
  write/
  conflicts/

The suite uses the shared integration harness for server lifecycle, HTTP requests, and isolated APP_BASE_PATH directories. The PCD harness layers PCD-specific setup, op seeding, writer drivers, and cache assertions on top of that shared harness.

Tests are grouped by contract:

Write tests call real app entrypoints and assert the ops emitted by the writer.

Conflict tests seed complete op histories and assert conflict history plus final compiled cache state.

Harness

The PCD harness owns the repeated setup needed by both write and conflict tests:

  • Create a minimal PCD repo on disk with deps/schema/ops/0.schema.sql.
  • Create a database_instances row with configurable conflict strategy, enabled state, and base-write access.
  • Seed published base ops, draft base ops, and published user ops.
  • Trigger compile through the same app path used by integration tests.
  • Query pcd_ops, latest pcd_op_history, and current conflicts.
  • Read compiled cache tables in deterministic order.
  • Drive writer entrypoints with a TestClient.
  • Capture emitted ops since a checkpoint.

The harness is intentionally thin. It provides setup and assertions, but it does not duplicate writer behavior or hide that compile consumes SQL ops.

Write Tests

Write tests verify the writer contract for one user action at a time.

Each write test follows the same shape:

  1. Create an isolated database instance and seed the smallest base state needed for the action (seededPcd does both, plus an initial compile).
  2. Record an op checkpoint with opCheckpoint(ctx).
  3. Submit the real form action through write.regex.* (or the writer for the entity under test).
  4. Query ops emitted after the checkpoint with userOpsSince(ctx, ...).
  5. Assert op count, metadata, desired state, grouping, generated flags, and SQL shape using the helpers in harness/pcd.ts and the colocated helpers.ts for the entity.

Example, taken from tests/integration/pcd/write/regex/update.test.ts:

test('scalar fields split into independent grouped ops', async () => {
	const ctx = await seededPcd('scalars', [
		base.regex({
			name: 'Scalar Regex',
			pattern: '\\bold\\b',
			description: 'Old',
			regex101Id: 'old101'
		})
	]);
	const checkpoint = opCheckpoint(ctx);

	await write.regex.update(ctx, 1, {
		name: 'Scalar Regex',
		pattern: '\\bnew\\b',
		description: 'New description',
		regex101Id: 'new101'
	});

	const ops = userOpsSince(ctx, checkpoint);
	assertEquals(ops.length, 3);
	assertOnlyField(ops, 'pattern');
	assertOnlyField(ops, 'description');
	assertOnlyField(ops, 'regex101_id');
	assertSameGroup(ops);
});

seededPcd and userOpsSince come from a per-entity helpers.ts (createScenarioFactory binds the spec's port and counter). base.regex, opCheckpoint, assertOnlyField, and assertSameGroup come from the shared harness. Each spec file owns its port (one server boot per file) and runs in parallel with siblings.

Doc Comment Format

Each write test sits behind a JSDoc block with three labeled sections so the inputs and expectations are visible without reading the body:

/**
 * Context
 *   Empty PCD (only schema seeded), compiled once.
 *
 * Submit
 *   POST /regular-expressions/{ctx.dbId}/new with form fields:
 *     name        = 'Created Regex'
 *     pattern     = '\bcreated\b'
 *     description = ''
 *     regex101Id  = ''
 *     tags        = '[]'
 *     layer       = 'user'
 *
 * Expect
 *   - userOpsSince(checkpoint).length === 1
 *   - op.metadata.operation     === 'create'
 *   - op.metadata.entity        === 'regular_expression'
 *   - op.metadata.name          === 'Created Regex'
 *   - op.desired_state.name        === 'Created Regex'
 *   - op.desired_state.pattern     === '\bcreated\b'
 *   - op.desired_state.description === null
 *   - op.desired_state.regex101_id === null
 *   - op.desired_state.tags        === []
 *   - op.sql matches /insert into "?regular_expressions"?/i
 */
test('minimal regex emits one create op', async () => {
	// ...
});

Section conventions:

  • Context: seeded entities and whether compile has run. Use Empty PCD if no entities are seeded.
  • Submit: the exact endpoint plus every form field and its value, including fields the harness fills with defaults. The writer sees the full request, so the doc shows it.
  • Expect: assertions as code-shaped bullets so the test body reads as their literal expansion. Cover op count, metadata fields, desired state values, and SQL match patterns. Failure-path tests use response.status and the SvelteKit failure payload shape.

Conflict Tests

Conflict tests verify replay and resolution behavior from complete op histories. Each scenario has three inputs:

  • Base: the original published upstream state.
  • User: local user intent written as user ops.
  • Upstream: newer published base ops that simulate a pull.

The same scenario runs under ask, align, and override when all three strategies are meaningful. The primary assertion is final compiled cache state. Op state and history assertions explain how the system got there.

Conflict strategy expectations:

Strategy Conflict expectation Final state expectation
ask conflicts remain as conflicted_pending upstream state plus clean user ops
align conflicted user ops are dropped upstream wins
override conflicted user ops are superseded or dropped, replacement ops are written user intent wins

Example:

test('regex pattern conflict resolves by strategy', async () => {
	await runConflictScenario({
		base: [
			base.regex({
				name: 'TestRegex',
				pattern: '\\boriginal\\b',
				description: ''
			})
		],
		user: [
			user.regex.update('TestRegex', {
				pattern: { from: '\\boriginal\\b', to: '\\buser\\b' }
			})
		],
		upstream: [
			upstream.regex.update('TestRegex', {
				pattern: { from: '\\boriginal\\b', to: '\\bupstream\\b' }
			})
		],
		expect: {
			ask: {
				conflicts: [
					{
						entity: 'regular_expression',
						field: 'pattern',
						reason: 'guard_mismatch'
					}
				],
				state: {
					regularExpressions: [{ name: 'TestRegex', pattern: '\\bupstream\\b' }]
				}
			},
			align: {
				conflicts: [],
				state: {
					regularExpressions: [{ name: 'TestRegex', pattern: '\\bupstream\\b' }]
				}
			},
			override: {
				conflicts: [],
				state: {
					regularExpressions: [{ name: 'TestRegex', pattern: '\\buser\\b' }]
				}
			}
		}
	});
});

Fixture Builders

Fixture builders remove repetitive seed SQL while preserving the op-level contract that compile consumes. Builders emit SQL plus the metadata and desired state needed by conflict handling.

base.regex({ name, pattern, description });
user.regex.update(name, { pattern: { from, to } });
upstream.regex.update(name, { pattern: { from, to } });

Raw SQL remains available for cases where a test needs exact control over the operation. The harness does not provide a full alternate writer DSL; real writes still go through the application writer.

Browser Boundary

Browser tests cover browser behavior only: rendering conflict lists, selecting resolutions, submitting forms, and displaying empty states. They do not own PCD correctness.

Writer, compile, conflict, and final state behavior belong in integration tests.