Skip to content

E2E Test Data Setup Runbook

Practical guide for creating test data to exercise Flows 4-7 (bid creation, response drafting, export, and template completion) without requiring real tender documents or AI API calls.

Key insight: Flows 6 (Export) and 7 (Template Completion) are fully deterministic. By inserting responses directly via SQL, you can test export and template completion with zero AI API calls.


RequirementHow to verify
Dev server runningbun dev — visit http://localhost:3000
Python worker (for template ops only)PYTHONUNBUFFERED=1 python3 scripts/bid_worker.py
Supabase connection.env.local has NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
Authenticated sessionLogged in at http://localhost:3000/login as admin or editor
Browser cookiesAll API calls below require the Supabase auth cookie from the browser

For API calls from the command line, you need an active session token. The easiest approach is to use the browser’s fetch() from the DevTools console, or use a tool like curl with the Supabase auth headers.

NEXT_PUBLIC_SUPABASE_URL=https://rovrymhhffssilaftdwd.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-key>
ANTHROPIC_API_KEY=<only-needed-for-Flow-5-AI-drafting>

All E2E test data uses a distinctive naming convention so it can be easily identified and cleaned up:

EntityConventionExample
Bid name[E2E Test] prefix[E2E Test] IT Support Services
BuyerE2E Test CorpE2E Test Corp
Template name[E2E Test] prefix[E2E Test] Response Template
  • Test data lives alongside real data in the same Supabase project
  • The [E2E Test] prefix makes it visually distinct in the bid list
  • Cleanup queries (see Section 8) filter on this prefix

// Browser DevTools console (or equivalent HTTP client)
const res = await fetch('/api/bids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '[E2E Test] IT Support Services',
buyer: 'E2E Test Corp',
deadline: new Date(Date.now() + 14 * 86400000).toISOString().split('T')[0],
description: 'E2E test bid for automated testing',
}),
});
const bid = await res.json();
const BID_ID = bid.id;
console.log('Bid ID:', BID_ID);

Expected response: HTTP 201 with the bid object. Status will be draft.

const questionsRes = await fetch(`/api/bids/${BID_ID}/questions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questions: [
{
section_name: 'Technical',
section_sequence: 1,
question_sequence: 1,
question_text:
'Describe your approach to providing IT support services including response times and escalation procedures.',
word_limit: 500,
category: 'technical',
},
{
section_name: 'Technical',
section_sequence: 1,
question_sequence: 2,
question_text:
'What experience does your organisation have delivering similar IT support contracts in the public sector?',
word_limit: 400,
category: 'experience',
},
{
section_name: 'Social Value',
section_sequence: 2,
question_sequence: 1,
question_text:
'Describe how you will deliver social value through this contract, including apprenticeships and local employment.',
word_limit: 300,
category: 'social_value',
},
{
section_name: 'Commercial',
section_sequence: 3,
question_sequence: 1,
question_text:
'Provide a breakdown of your pricing model including any assumptions or exclusions.',
word_limit: 600,
category: 'commercial',
},
],
}),
});
const { questions, count } = await questionsRes.json();
console.log(`Created ${count} questions`);
// Save question IDs for later use
const QUESTION_IDS = questions.map((q) => q.id);
console.log('Question IDs:', QUESTION_IDS);

Expected response: HTTP 201 with { questions: [...], count: 4 }.

The bid state machine requires sequential transitions. Advance through the required states:

// draft -> questions_extracted
await fetch(`/api/bids/${BID_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'questions_extracted' }),
});
// questions_extracted -> matching
await fetch(`/api/bids/${BID_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'matching' }),
});
// matching -> drafting
await fetch(`/api/bids/${BID_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'drafting' }),
});
console.log('Bid advanced to drafting');

Each PATCH returns the updated bid object. The state machine enforces valid transitions:

draft -> questions_extracted -> matching -> drafting -> in_review -> ready_for_export -> submitted

This is the key technique for testing Flows 6 and 7 without any AI API calls. Instead of calling the drafting endpoint (which invokes Claude), insert responses directly via SQL.

Option A: Direct SQL (via Supabase MCP or Dashboard)

Section titled “Option A: Direct SQL (via Supabase MCP or Dashboard)”
-- Insert test responses for each question
-- Replace the UUIDs with your actual BID_ID and QUESTION_IDS
INSERT INTO bid_responses (question_id, response_text, review_status, drafted_by, metadata)
VALUES
(
'<QUESTION_ID_1>',
'<p>Our IT support approach provides a comprehensive three-tier response structure. <strong>Priority 1 (Critical)</strong> issues affecting business operations receive a 15-minute response time with continuous work until resolution. <strong>Priority 2 (High)</strong> issues receive a 1-hour response with resolution within 4 hours. <strong>Priority 3 (Standard)</strong> requests are acknowledged within 4 hours and resolved within 2 business days.</p><p>Our escalation procedure follows a clear chain: Service Desk Analyst to Technical Lead (30 minutes), Technical Lead to Service Delivery Manager (1 hour), and Service Delivery Manager to Account Director (2 hours).</p>',
'approved',
'<YOUR_USER_UUID>',
'{"quality_data": {"word_count": 95, "overall_score": 85, "word_limit_compliance": true}}'::jsonb
),
(
'<QUESTION_ID_2>',
'<p>We have delivered IT support contracts across the public sector for over 10 years. Key examples include a 5-year contract with NHS Digital providing service desk and desktop support to 3,000 users, and a 3-year contract with the Department for Education covering infrastructure monitoring and incident management.</p><p>These contracts have consistently achieved 99.5% SLA compliance and maintained customer satisfaction scores above 4.5/5.</p>',
'approved',
'<YOUR_USER_UUID>',
'{"quality_data": {"word_count": 75, "overall_score": 80, "word_limit_compliance": true}}'::jsonb
),
(
'<QUESTION_ID_3>',
'<p>Social value is integral to our delivery model. We commit to creating a minimum of 2 apprenticeship positions per year of contract operation, recruiting from the local area within a 30-mile radius of the delivery site.</p><p>We will partner with local colleges to provide work experience placements and contribute 40 hours of pro-bono IT support to local charities annually.</p>',
'approved',
'<YOUR_USER_UUID>',
'{"quality_data": {"word_count": 65, "overall_score": 78, "word_limit_compliance": true}}'::jsonb
),
(
'<QUESTION_ID_4>',
'<p>Our pricing model is based on a fixed monthly service charge covering all BAU support activities. The monthly charge includes: Service Desk (8am-6pm weekdays), on-site engineering (2 FTE), infrastructure monitoring (24/7), and quarterly service reviews.</p><p><strong>Assumptions:</strong> Maximum 500 supported users, standard desktop/laptop estate, existing network infrastructure in place. <strong>Exclusions:</strong> Hardware procurement, project-based work, and out-of-hours support (available as an add-on).</p>',
'approved',
'<YOUR_USER_UUID>',
'{"quality_data": {"word_count": 85, "overall_score": 82, "word_limit_compliance": true}}'::jsonb
);

Option B: Via API (if you want responses visible in the response editor)

Section titled “Option B: Via API (if you want responses visible in the response editor)”

If you prefer to create responses via the API, you can use the response update endpoint after creating empty records. However, the drafting endpoints require ANTHROPIC_API_KEY. The SQL approach above is simpler for testing.

If you inserted responses with review_status: 'approved' in the SQL above, this step is already done. If you used a different status, approve each response:

// Fetch responses to get their IDs
const respRes = await fetch(`/api/bids/${BID_ID}/questions`);
const { questions: qs } = await respRes.json();
for (const q of qs) {
if (q.response?.id) {
await fetch(`/api/bids/${BID_ID}/responses/${q.response.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_status: 'approved' }),
});
}
}
console.log('All responses approved');
// drafting -> in_review
await fetch(`/api/bids/${BID_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_review' }),
});
// in_review -> ready_for_export
await fetch(`/api/bids/${BID_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ready_for_export' }),
});
console.log('Bid ready for export');

With the bid in ready_for_export state and approved responses in place, export is fully deterministic — no AI calls needed.

const docxRes = await fetch(`/api/bids/${BID_ID}/export/docx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
// Download the file
const blob = await docxRes.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'test-export.docx';
a.click();

Verification checklist:

  • File downloads without errors
  • Document contains bid name as title
  • All 4 questions are present with section headings
  • All 4 approved responses are rendered
  • Word count metadata is present
  • Formatting (bold, paragraphs) is preserved from HTML
const xlsxRes = await fetch(`/api/bids/${BID_ID}/export/xlsx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const blob = await xlsxRes.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'test-export.xlsx';
a.click();

Verification checklist:

  • File downloads without errors
  • Spreadsheet has headers: Section, Question, Word Limit, Response, Status
  • All 4 questions and responses are present
  • Data is correctly aligned in columns

Both export endpoints accept optional configuration:

// DOCX with all options
await fetch(`/api/bids/${BID_ID}/export/docx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_cover: true,
include_toc: true,
include_citations: false,
include_unanswered: false,
company_name: 'E2E Test Corp',
}),
});

Template completion requires the Python worker for analysis and filling steps. Ensure it is running before starting.

Use the test fixture at __tests__/fixtures/simple-template.docx:

const formData = new FormData();
// In a browser, use a file input or fetch the fixture
const fileInput = document.querySelector('input[type="file"]');
formData.append('file', fileInput.files[0]);
formData.append('name', '[E2E Test] Response Template');
const uploadRes = await fetch(`/api/bids/${BID_ID}/templates`, {
method: 'POST',
body: formData, // No Content-Type header -- browser sets multipart boundary
});
const template = await uploadRes.json();
const TEMPLATE_ID = template.id;
console.log('Template ID:', TEMPLATE_ID);

Expected response: HTTP 201 with template object, status: 'uploaded'.

const analyseRes = await fetch(
`/api/bids/${BID_ID}/templates/${TEMPLATE_ID}/analyse`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
},
);
const analyseJob = await analyseRes.json();
console.log('Analysis job:', analyseJob);

Expected response: HTTP 202 with { job_id, status: 'queued' }.

The Python worker (bid_worker.py) picks up the job from processing_queue, downloads the template from Supabase Storage, identifies table cells that contain questions, and writes the results to template_fields.

Wait for completion: Poll the template status or watch the worker logs. Analysis typically takes 5-15 seconds.

// Poll until status changes from 'analysing'
const poll = async () => {
const r = await fetch(`/api/bids/${BID_ID}/templates`);
const { templates } = await r.json();
const t = templates.find((t) => t.id === TEMPLATE_ID);
console.log('Template status:', t?.status, 'Fields:', t?.field_count);
return t?.status;
};
// Call poll() every few seconds until status is 'analysed'
const mapRes = await fetch(
`/api/bids/${BID_ID}/templates/${TEMPLATE_ID}/auto-map`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threshold: 0.5 }), // Lower threshold for test data
},
);
const mapResult = await mapRes.json();
console.log(`Mapped ${mapResult.mapped}/${mapResult.total} fields`);

Expected response: { mapped: N, unmapped: M, total: T, mappings: [...] }.

Auto-mapping uses Dice coefficient text similarity (no AI). It matches template field question text against bid question text.

After auto-mapping, fields have mapping_status: 'unreviewed'. Confirm them so they are included in the fill:

// Fetch the mappings from auto-map result
const confirmRes = await fetch(
`/api/bids/${BID_ID}/templates/${TEMPLATE_ID}/fields/bulk-update`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mappings: mapResult.mappings.map((m) => ({
field_id: m.field_id,
question_id: m.question_id,
mapping_status: 'confirmed',
})),
}),
},
);
const confirmResult = await confirmRes.json();
console.log(`Confirmed ${confirmResult.updated} mappings`);
const fillRes = await fetch(
`/api/bids/${BID_ID}/templates/${TEMPLATE_ID}/fill`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skip_unmapped: true,
skip_unapproved: false,
fallback_to_draft: true,
response_variant: 'standard',
}),
},
);
const fillJob = await fillRes.json();
console.log('Fill job:', fillJob);

Expected response: HTTP 202 with { job_id, status: 'queued', fields_to_fill: N }.

Wait for completion: The Python worker fills the template by writing response text into the identified table cells, preserving the original formatting. This typically takes 10-30 seconds.

// After fill completes, fetch completions
const completionsRes = await fetch(`/api/bids/${BID_ID}/templates`);
const { templates } = await completionsRes.json();
const filled = templates.find((t) => t.id === TEMPLATE_ID);
console.log('Template status:', filled?.status);
console.log('Completions count:', filled?.completions_count);
// Get the completion ID from template_completions
// (The fill job creates a completion record with the filled document)
// You can query this via the templates list or directly:
// First, find the latest completion for this template
const tplRes = await fetch(`/api/bids/${BID_ID}/templates`);
const { templates: tpls } = await tplRes.json();
const tpl = tpls.find((t) => t.id === TEMPLATE_ID);
// If completions_count > 0, get the download URL
// The completion ID is available from the template_completions table
// For now, use the UI at /bid/{BID_ID}/templates to download

Verification checklist:

  • Template analysis identifies the correct number of table cells
  • Auto-map matches fields to the right questions
  • Fill completes without errors
  • Downloaded document has responses in the correct cells
  • Original formatting (fonts, colours, borders) is preserved
  • Word limits are respected (truncation warnings if exceeded)

Understanding where AI is and is not involved helps determine what can be tested without API keys.

OperationAI involved?Details
Bid creationNoSimple CRUD
Question extraction from tender docYesClaude extracts questions from uploaded documents
Adding questions manuallyNoDirect database insert
KB matchingYesUses embeddings for semantic search
Response draftingYeslib/bid-drafting.ts calls Claude
Response editing/approvalNoDirect database update
Export DOCX/XLSXNoDeterministic document generation
Template uploadNoFile storage
Template analysisNoPython analyse_template.py — regex/heuristic
Template auto-mapNoDice coefficient text similarity
Template fillNoPython fill_template.py — python-docx cell writes

Summary: To test the full export and template completion flows, you only need to bypass two AI-dependent steps (question extraction and response drafting) by inserting data directly via SQL.


// Delete via the API (cascades to template_fields, template_completions)
await fetch(`/api/bids/${BID_ID}/templates/${TEMPLATE_ID}`, {
method: 'DELETE',
});

Delete test bid (cascades to questions and responses)

Section titled “Delete test bid (cascades to questions and responses)”
// Requires admin role
await fetch(`/api/bids/${BID_ID}`, {
method: 'DELETE',
});

The DELETE /api/bids/:id endpoint deletes the project record. Due to cascading foreign keys, this also removes:

  • bid_questions (via project_id)
  • bid_responses (via question_id -> bid_questions)
  • templates (via project_id)
  • template_fields (via template_id -> templates)
  • template_completions (via template_id -> templates)
-- Find any test bids that were not cleaned up
SELECT id, name, domain_metadata->>'status' as status, created_at
FROM projects
WHERE type = 'bid'
AND name LIKE '[E2E Test]%'
ORDER BY created_at DESC;
-- Find orphaned test responses (if questions were deleted but responses remain)
SELECT r.id, r.question_id, r.review_status
FROM bid_responses r
LEFT JOIN bid_questions q ON r.question_id = q.id
WHERE q.id IS NULL;
-- Find test templates
SELECT id, name, project_id, status
FROM templates
WHERE name LIKE '[E2E Test]%';

Template files are stored in the templates Supabase Storage bucket. Deleting the template record via the API also removes the storage files. If storage files are orphaned, clean them up manually:

-- List storage objects for a specific bid
SELECT name, created_at
FROM storage.objects
WHERE bucket_id = 'templates'
AND name LIKE '<BID_ID>/%';

This script runs all the steps above in sequence. Paste it into the browser DevTools console while logged in as an admin/editor.

// ============================================
// E2E Test: Full bid -> export happy path
// ============================================
// Prerequisites: dev server running, logged in
(async () => {
const BASE = ''; // relative URLs work in DevTools
// 1. Create bid
console.log('Step 1: Creating bid...');
const bidRes = await fetch(`${BASE}/api/bids`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `[E2E Test] IT Support ${Date.now()}`,
buyer: 'E2E Test Corp',
deadline: new Date(Date.now() + 14 * 86400000)
.toISOString()
.split('T')[0],
}),
});
if (!bidRes.ok) throw new Error(`Create bid failed: ${bidRes.status}`);
const bid = await bidRes.json();
console.log(' Bid ID:', bid.id);
// 2. Add questions
console.log('Step 2: Adding questions...');
const qRes = await fetch(`${BASE}/api/bids/${bid.id}/questions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questions: [
{
section_name: 'Technical',
section_sequence: 1,
question_sequence: 1,
question_text:
'Describe your IT support approach and response times.',
word_limit: 500,
},
{
section_name: 'Experience',
section_sequence: 2,
question_sequence: 1,
question_text:
'What public sector IT support experience do you have?',
word_limit: 400,
},
{
section_name: 'Social Value',
section_sequence: 3,
question_sequence: 1,
question_text:
'How will you deliver social value through this contract?',
word_limit: 300,
},
{
section_name: 'Commercial',
section_sequence: 4,
question_sequence: 1,
question_text: 'Provide your pricing model breakdown.',
word_limit: 600,
},
],
}),
});
if (!qRes.ok) throw new Error(`Add questions failed: ${qRes.status}`);
const { questions } = await qRes.json();
console.log(` Created ${questions.length} questions`);
// 3. Advance state: draft -> questions_extracted -> matching -> drafting
console.log('Step 3: Advancing bid state...');
for (const status of ['questions_extracted', 'matching', 'drafting']) {
const r = await fetch(`${BASE}/api/bids/${bid.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!r.ok)
throw new Error(`State transition to ${status} failed: ${r.status}`);
}
console.log(' Bid now in drafting state');
// 4. NOTE: Response insertion requires direct SQL or Supabase MCP
// See Section 4 of e2e-test-setup.md for the INSERT statements.
// After inserting responses, continue with steps 5-6 below.
console.log('Step 4: Insert responses via SQL (see runbook Section 4)');
console.log(' Question IDs for SQL:');
questions.forEach((q, i) => console.log(` Q${i + 1}: ${q.id}`));
// 5. After responses are inserted, advance to ready_for_export
// Uncomment the following once responses exist:
/*
for (const status of ['in_review', 'ready_for_export']) {
await fetch(`${BASE}/api/bids/${bid.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
}
console.log(' Bid ready for export');
// 6. Export DOCX
console.log('Step 6: Exporting DOCX...');
const docxRes = await fetch(`${BASE}/api/bids/${bid.id}/export/docx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!docxRes.ok) throw new Error(`DOCX export failed: ${docxRes.status}`);
const blob = await docxRes.blob();
console.log(` DOCX generated: ${(blob.size / 1024).toFixed(1)} KB`);
// Download it
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'e2e-test-export.docx';
a.click();
URL.revokeObjectURL(url);
console.log(' Download triggered');
*/
console.log('\nDone! Bid ID for cleanup:', bid.id);
})();

When automated E2E tests are implemented, the test data setup described here will serve as the seed script. Key considerations:

  • Supabase service role client can insert responses directly without auth cookies, bypassing RLS
  • Playwright fixtures can wrap the create/cleanup cycle in beforeAll/afterAll
  • AI mocking: The only AI boundary is lib/bid-drafting.ts (generateBidResponse). Mocking this function with static responses enables fully deterministic E2E tests including the drafting flow
  • Template analysis and filling are already deterministic (Python worker uses regex/heuristics, not AI)
  • Export generation is already deterministic (docx/xlsx library calls)

E2E tests require three test users in auth.users and public.user_roles before the suite runs. e2e/global-setup.ts hard-fails if any are missing or have the wrong role.

TEST_USER_1_PASSWORD=<password for admin>
TEST_USER_2_PASSWORD=<password for editor>
TEST_USER_3_PASSWORD=<password for viewer>

These are already set in .env/.env.local for the live project. For new demo or Phew DB rebuilds, generate fresh passwords and add them to the new project’s secrets store.

Terminal window
bun run seed:e2e-users # provision or verify all 3 users
bun run seed:e2e-users --check # verify-only, exit 1 on mismatch
bun run seed:e2e-users --dry-run # preview without writing

The script uses supabase.auth.admin.createUser() (NOT raw SQL) so the resulting auth.users rows have the correct GoTrue shape — token columns initialised to '', accompanying auth.identities rows. This is the same gotcha that caused the S156 incident with the pipeline service account.

  • After supabase db reset && supabase db push on a fresh project
  • Before the first bun run test:e2e on any new environment
  • As part of the demo DB / Phew re-ingest rebuild runbook
  • Whenever bun run test:e2e fails with “test users not found in auth.users”

The script is idempotent — running it on an environment where users already exist is a no-op aside from re-asserting the role assignments.

If seed:e2e-users cannot run (e.g. service key not available), the operator can create the three users manually via the Supabase dashboard:

  1. Authentication → Users → Add user (NOT raw SQL — use the UI)
  2. Set the email + a password matching TEST_USER_N_PASSWORD
  3. After all three exist, run this SQL to assign roles:
    INSERT INTO public.user_roles (user_id, role)
    SELECT id, CASE
    WHEN email = 'test.user1@test-kb-aish.co.uk' THEN 'admin'
    WHEN email = 'test.user2@test-kb-aish.co.uk' THEN 'editor'
    WHEN email = 'test.user3@test-kb-aish.co.uk' THEN 'viewer'
    END
    FROM auth.users
    WHERE email IN (
    'test.user1@test-kb-aish.co.uk',
    'test.user2@test-kb-aish.co.uk',
    'test.user3@test-kb-aish.co.uk'
    )
    ON CONFLICT (user_id) DO UPDATE SET role = EXCLUDED.role;

Generated: Session 46, 5 March 2026 Updated: Session 156, 8 April 2026 — added §11 test user provisioning