mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 20:05:25 +08:00
test(regression): add selectable resource lifecycle cases (#2794)
This commit is contained in:
@@ -173,7 +173,7 @@
|
||||
{"id": "auth.revocation",
|
||||
"area": "auth", "kind": "blackbox", "priority": "P0",
|
||||
"description": "Revoked credentials stop authorizing new requests",
|
||||
"bindings": []
|
||||
"bindings": [{"profile": "scenarios", "case": "auth_revocation", "checks": ["credential_active", "credential_revoked", "key_status", "key_cleanup"]}]
|
||||
},
|
||||
{"id": "workspace.read_isolation",
|
||||
"area": "workspace", "kind": "blackbox", "priority": "P0",
|
||||
@@ -248,7 +248,7 @@
|
||||
{"id": "agent.direct_execution",
|
||||
"area": "agent", "kind": "blackbox", "priority": "P1",
|
||||
"description": "Standalone Agent entrypoint completes through the public API",
|
||||
"bindings": []
|
||||
"bindings": [{"profile": "scenarios", "case": "agent_direct_execution", "checks": ["agent_response", "agent_terminal", "agent_events", "agent_cleanup"]}]
|
||||
},
|
||||
{"id": "persistence.postgres_parity",
|
||||
"area": "persistence", "kind": "blackbox", "priority": "P1",
|
||||
@@ -265,6 +265,7 @@
|
||||
"description": "A real embedding model retrieves relevant fixed documents",
|
||||
"bindings": []
|
||||
},
|
||||
{"id": "retrieval.delete_visibility", "area": "retrieval", "kind": "blackbox", "priority": "P1", "description": "Deleting one ingested document removes its retrieval results while preserving a second document", "bindings": [{"profile": "scenarios", "case": "retrieval_delete_visibility", "checks": ["retrieval_initial", "retrieval_deleted", "retrieval_control", "document_cleanup"]}]},
|
||||
{"id": "execution.persisted_terminal",
|
||||
"area": "execution", "kind": "graybox", "priority": "P0",
|
||||
"description": "Each executed request has a unique completed persisted run and terminal event",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Optional local scenarios share the Suite's verified Workspace and credentials."""
|
||||
|
||||
from contextlib import closing
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
GROUPS = {'resources': 'regression_resource_cases'}
|
||||
|
||||
|
||||
class ScenarioUnavailable(RuntimeError):
|
||||
"""An explicit prerequisite is missing; this is not a passed assertion."""
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def endpoint(suite, path):
|
||||
require(path.startswith('/') and not path.startswith('//'), 'Scenario API path must be relative to Core')
|
||||
base = suite.connection.get('endpoint')
|
||||
require(bool(base), 'Scenario needs the endpoint verified by lbctl whoami')
|
||||
return base.rstrip('/') + path
|
||||
|
||||
|
||||
def auth_headers(suite):
|
||||
source = suite.connection.get('credential_source', '')
|
||||
key = suite.env.get(source.removeprefix('env:')) if source.startswith('env:') else None
|
||||
require(bool(key), 'Scenario requires environment-backed lbctl credentials')
|
||||
if key not in suite.secrets:
|
||||
suite.secrets.append(key)
|
||||
return {'Authorization': 'Bearer ' + key, 'X-Workspace-Id': suite.variables['workspace_uuid']}
|
||||
|
||||
|
||||
headers = auth_headers
|
||||
|
||||
|
||||
def api(suite, method, path, body=None, headers=None):
|
||||
request_headers = dict(auth_headers(suite) if headers is None else headers)
|
||||
request_headers.setdefault('X-Workspace-Id', suite.variables['workspace_uuid'])
|
||||
if body is not None:
|
||||
request_headers['Content-Type'] = 'application/json'
|
||||
request = urllib.request.Request(
|
||||
endpoint(suite, path),
|
||||
method=method,
|
||||
headers=request_headers,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
try:
|
||||
response = opener.open(request, timeout=suite.timeout(90))
|
||||
except urllib.error.HTTPError as exc:
|
||||
response = exc
|
||||
with response:
|
||||
return response.status, json.load(response)
|
||||
|
||||
|
||||
def record_run(suite, row, run_id, expected_status):
|
||||
"""Register exact run ownership for the independent final ledger check."""
|
||||
require(expected_status in {'completed', 'failed', 'cancelled', 'timeout'}, 'Expected Run status must be terminal')
|
||||
path = Path(suite.path(suite.profile['smoke']['ledger_db'])).resolve()
|
||||
deadline = time.monotonic() + suite.timeout(30)
|
||||
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=5)) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
while True:
|
||||
run = connection.execute(
|
||||
'SELECT run_id, conversation_id, status, finished_at FROM agent_run '
|
||||
'WHERE workspace_id=? AND run_id=? AND id>?',
|
||||
(suite.variables['workspace_uuid'], run_id, row['after_id']),
|
||||
).fetchone()
|
||||
if run and run['finished_at']:
|
||||
observed = dict(run)
|
||||
observed.update(expected_status=expected_status, after_id=row['after_id'])
|
||||
row.setdefault('scenario_runs', []).append(observed)
|
||||
require(run['status'] == expected_status, f'Run ended as {run["status"]}; expected {expected_status}')
|
||||
return observed
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError('Scenario Run did not reach a persisted terminal state')
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def modules():
|
||||
return {name: importlib.import_module(module) for name, module in GROUPS.items()}
|
||||
|
||||
|
||||
def add_cases(profile, groups):
|
||||
groups = list(groups)
|
||||
require(len(groups) == len(set(groups)), 'Scenario groups must not repeat')
|
||||
require(not (set(groups) - GROUPS.keys()), 'Unknown local scenario group')
|
||||
for name in groups:
|
||||
importlib.import_module(GROUPS[name]).add_cases(profile)
|
||||
profile['scenario_groups'] = groups
|
||||
|
||||
|
||||
def run_case(suite, case, row):
|
||||
for name, module in modules().items():
|
||||
if case['scenario'] in module.CASE_NAMES:
|
||||
require(name in suite.profile.get('scenario_groups', []), f'Scenario group is disabled: {name}')
|
||||
return module.run_case(suite, case, row)
|
||||
raise ValueError(f'Unknown local scenario: {case["scenario"]}')
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Optional resource lifecycle scenarios against the authenticated Workspace APIs."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from uuid import uuid4
|
||||
|
||||
from regression_coverage import checked
|
||||
from regression_local_cases import ScenarioUnavailable, api, require
|
||||
from regression_suite import resolve
|
||||
|
||||
GROUP = 'resources'
|
||||
CASE_NAMES = {'auth_revocation', 'agent_direct_execution', 'retrieval_delete_visibility'}
|
||||
|
||||
|
||||
def add_cases(profile):
|
||||
resources = {item['id']: item for item in profile['resources']}
|
||||
base = copy.deepcopy(resources['empty_knowledge_base'])
|
||||
base.update(id='resource_knowledge_base')
|
||||
base['body']['name'] = 'regression document lifecycle'
|
||||
base.pop('documents', None)
|
||||
profile['resources'].append(base)
|
||||
for name, stage, expected in (
|
||||
('auth_revocation', 'auth', 'LANGBOT_AUTH_REVOCATION_OK'),
|
||||
('agent_direct_execution', 'agent', 'LANGBOT_CHAT_OK'),
|
||||
('retrieval_delete_visibility', 'retrieval', 'LANGBOT_DOCUMENT_DELETE_OK'),
|
||||
):
|
||||
profile['cases'].append({'name': name, 'scenario': name, 'stage': stage, 'expect': expected})
|
||||
|
||||
|
||||
def data(suite, method, path, body=None, *, headers=None):
|
||||
status, response = api(suite, method, path, body, headers=headers)
|
||||
require(
|
||||
status == 200 and isinstance(response, dict) and response.get('code') == 0,
|
||||
f'{method} {path} did not return a successful response (HTTP {status})',
|
||||
)
|
||||
return response.get('data', {})
|
||||
|
||||
|
||||
def journal(suite, row):
|
||||
row.setdefault('resource_journal', f'resource-case-{uuid4().hex}.json')
|
||||
suite.write(
|
||||
row['resource_journal'],
|
||||
{
|
||||
'workspace_uuid': suite.variables['workspace_uuid'],
|
||||
'resources': row.get('owned_resources', []),
|
||||
'cleanup_errors': row.get('cleanup_errors', []),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def cleanup(suite, row, assertion, action):
|
||||
original = sys.exc_info()[1]
|
||||
try:
|
||||
checked(row, assertion, action)
|
||||
except Exception as exc:
|
||||
message = f'{type(exc).__name__}: {exc}'
|
||||
for secret in suite.secrets:
|
||||
message = message.replace(secret, '[REDACTED]')
|
||||
row.setdefault('cleanup_errors', []).append(message)
|
||||
if original is None:
|
||||
raise
|
||||
finally:
|
||||
journal(suite, row)
|
||||
|
||||
|
||||
def auth_revocation(suite, case, row):
|
||||
token = suite.env.get('LANGBOT_QA_USER_TOKEN')
|
||||
if not token:
|
||||
raise ScenarioUnavailable('auth.revocation requires LANGBOT_QA_USER_TOKEN with api_key.manage')
|
||||
if token not in suite.secrets:
|
||||
suite.secrets.append(token)
|
||||
manager = {'Authorization': 'Bearer ' + token}
|
||||
name = 'qa-revoke-' + uuid4().hex
|
||||
owned = {'kind': 'api-key', 'name': name, 'state': 'creating'}
|
||||
row.setdefault('owned_resources', []).append(owned)
|
||||
journal(suite, row)
|
||||
|
||||
def revoke_owned():
|
||||
if 'id' not in owned:
|
||||
keys = data(suite, 'GET', '/api/v1/apikeys', headers=manager)['keys']
|
||||
matches = [key for key in keys if key.get('name') == name]
|
||||
require(len(matches) == 1, 'Created key identity is unresolved; inspect the resource journal')
|
||||
owned['id'] = matches[0]['id']
|
||||
journal(suite, row)
|
||||
path = f'/api/v1/apikeys/{owned["id"]}'
|
||||
key = data(suite, 'GET', path, headers=manager)['key']
|
||||
if key['status'] != 'revoked':
|
||||
data(suite, 'DELETE', path, headers=manager)
|
||||
key = data(suite, 'GET', path, headers=manager)['key']
|
||||
require(key['status'] == 'revoked', 'Owned credential remains active after cleanup')
|
||||
owned['state'] = 'revoked'
|
||||
|
||||
try:
|
||||
created = data(suite, 'POST', '/api/v1/apikeys', {'name': name, 'scopes': ['resource.view']}, headers=manager)[
|
||||
'key'
|
||||
]
|
||||
owned.update(id=created['id'], uuid=created['uuid'], state='active')
|
||||
journal(suite, row)
|
||||
secret = created['key']
|
||||
require(isinstance(secret, str) and bool(secret), 'Key creation did not return its one-time secret')
|
||||
suite.secrets.append(secret)
|
||||
child = {'X-API-Key': secret}
|
||||
identity = data(suite, 'GET', '/api/v1/system/context', headers=child)
|
||||
checked(
|
||||
row,
|
||||
'credential_active',
|
||||
lambda: require(
|
||||
identity.get('workspace_uuid') == suite.variables['workspace_uuid']
|
||||
and identity.get('api_key_id') == owned['uuid'],
|
||||
'New credential did not authenticate as its own Workspace key',
|
||||
),
|
||||
)
|
||||
data(suite, 'GET', '/api/v1/pipelines', headers=child)
|
||||
data(suite, 'DELETE', f'/api/v1/apikeys/{owned["id"]}', headers=manager)
|
||||
status, response = api(suite, 'GET', '/api/v1/pipelines', headers=child)
|
||||
checked(
|
||||
row,
|
||||
'credential_revoked',
|
||||
lambda: require(
|
||||
status == 401 and isinstance(response, dict) and response.get('code') != 0,
|
||||
'Revoked credential still authorizes resource access',
|
||||
),
|
||||
)
|
||||
key = data(suite, 'GET', f'/api/v1/apikeys/{owned["id"]}', headers=manager)['key']
|
||||
checked(
|
||||
row,
|
||||
'key_status',
|
||||
lambda: require(
|
||||
key.get('status') == 'revoked' and key.get('secret_available') is False and 'key' not in key,
|
||||
'Revoked key readback has incorrect status or exposes its secret',
|
||||
),
|
||||
)
|
||||
return {'result': case['expect'], 'key_id': owned['id'], 'status': 'revoked'}
|
||||
finally:
|
||||
cleanup(suite, row, 'key_cleanup', revoke_owned)
|
||||
|
||||
|
||||
def agent_direct_execution(suite, case, row):
|
||||
template = next(item for item in suite.profile['resources'] if item['id'] == 'chat_pipeline')
|
||||
config = resolve(copy.deepcopy(template['body']['config']['ai']), suite.variables)
|
||||
name = 'qa-agent-' + uuid4().hex
|
||||
conversation = 'qa-direct-' + uuid4().hex
|
||||
owned = {'kind': 'agent', 'name': name, 'state': 'creating'}
|
||||
row.setdefault('owned_resources', []).append(owned)
|
||||
journal(suite, row)
|
||||
|
||||
def delete_owned():
|
||||
if 'uuid' not in owned:
|
||||
agents = data(suite, 'GET', '/api/v1/agents')['agents']
|
||||
matches = [agent for agent in agents if agent.get('name') == name and agent.get('kind') == 'agent']
|
||||
require(len(matches) == 1, 'Created Agent identity is unresolved; inspect the resource journal')
|
||||
owned['uuid'] = matches[0]['uuid']
|
||||
journal(suite, row)
|
||||
path = f'/api/v1/agents/{owned["uuid"]}'
|
||||
status, _ = api(suite, 'GET', path)
|
||||
if status != 404:
|
||||
require(status == 200, 'Cannot read owned Agent before cleanup')
|
||||
data(suite, 'DELETE', path)
|
||||
status, _ = api(suite, 'GET', path)
|
||||
require(status == 404, 'Owned Agent remains visible after cleanup')
|
||||
owned['state'] = 'deleted'
|
||||
|
||||
try:
|
||||
created = data(suite, 'POST', '/api/v1/agents', {'name': name, 'kind': 'agent', 'config': config})
|
||||
owned.update(uuid=created['uuid'], state='created')
|
||||
journal(suite, row)
|
||||
path = f'/api/v1/agents/{owned["uuid"]}'
|
||||
reply = data(
|
||||
suite, 'POST', path + '/debug', {'text': 'Run the direct Agent scenario', 'conversation_id': conversation}
|
||||
)
|
||||
runs = data(suite, 'GET', path + '/runs')['items']
|
||||
matches = [run for run in runs if run.get('conversation_id') == conversation]
|
||||
require(len(matches) == 1, 'Direct Agent request did not produce exactly one matching Run')
|
||||
run = matches[0]
|
||||
row.setdefault('scenario_runs', []).append(
|
||||
{
|
||||
'run_id': run['run_id'],
|
||||
'conversation_id': conversation,
|
||||
'expected_status': 'completed',
|
||||
'after_id': row['after_id'],
|
||||
}
|
||||
)
|
||||
checked(
|
||||
row,
|
||||
'agent_terminal',
|
||||
lambda: require(
|
||||
run.get('status') == 'completed' and bool(run.get('finished_at')),
|
||||
'Direct Agent Run did not reach completed status',
|
||||
),
|
||||
)
|
||||
checked(
|
||||
row,
|
||||
'agent_response',
|
||||
lambda: require(
|
||||
reply.get('conversation_id') == conversation and case['expect'] in reply.get('final_text', ''),
|
||||
'Direct Agent returned the wrong conversation or business reply',
|
||||
),
|
||||
)
|
||||
events = data(suite, 'GET', path + f'/runs/{run["run_id"]}/events')
|
||||
checked(
|
||||
row,
|
||||
'agent_events',
|
||||
lambda: require(
|
||||
events.get('run', {}).get('run_id') == run['run_id']
|
||||
and any(
|
||||
event.get('type') == 'run.completed' and event.get('run_id') == run['run_id']
|
||||
for event in events.get('items', [])
|
||||
),
|
||||
'Direct Agent completion event is missing',
|
||||
),
|
||||
)
|
||||
return {'result': reply['final_text'], 'run_id': run['run_id'], 'conversation_id': conversation}
|
||||
finally:
|
||||
cleanup(suite, row, 'agent_cleanup', delete_owned)
|
||||
|
||||
|
||||
def retrieval_delete_visibility(suite, case, row):
|
||||
kb_uuid = suite.variables['resource_knowledge_base']
|
||||
path = f'/api/v1/knowledge/bases/{kb_uuid}'
|
||||
before = {item['uuid'] for item in data(suite, 'GET', path + '/files')['files']}
|
||||
owned = {'kind': 'knowledge-files', 'knowledge_base_uuid': kb_uuid, 'files': [], 'state': 'ingesting'}
|
||||
row.setdefault('owned_resources', []).append(owned)
|
||||
journal(suite, row)
|
||||
|
||||
def files():
|
||||
return [item for item in data(suite, 'GET', path + '/files')['files'] if item['uuid'] not in before]
|
||||
|
||||
def cleanup_documents():
|
||||
remaining = files()
|
||||
owned['files'] = [{'uuid': item['uuid'], 'status': item['status']} for item in remaining]
|
||||
journal(suite, row)
|
||||
unresolved = [item['uuid'] for item in remaining if item['status'] in {'pending', 'processing', 'interrupted'}]
|
||||
for item in remaining:
|
||||
if item['uuid'] not in unresolved:
|
||||
data(suite, 'DELETE', path + '/files/' + item['uuid'])
|
||||
require(
|
||||
not unresolved, 'Document ingestion outcome is unresolved; retain the owned knowledge base for inspection'
|
||||
)
|
||||
require(not files(), 'Owned documents remain visible after cleanup')
|
||||
owned['state'] = 'deleted'
|
||||
|
||||
def retrieve(query):
|
||||
found = data(suite, 'POST', path + '/retrieve', {'query': query, 'retrieval_settings': {'top_k': 10}})[
|
||||
'results'
|
||||
]
|
||||
require(
|
||||
isinstance(found, list)
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get('metadata'), dict)
|
||||
and isinstance(item.get('content'), list)
|
||||
for item in found
|
||||
),
|
||||
'Malformed retrieval response',
|
||||
)
|
||||
return found
|
||||
|
||||
def contains(found, document, marker):
|
||||
return any(
|
||||
item.get('metadata', {}).get('file_id') == document['engine_document_id']
|
||||
and marker in json.dumps(item.get('content', []), ensure_ascii=False)
|
||||
for item in found
|
||||
)
|
||||
|
||||
try:
|
||||
documents, markers = [], ['obsolete' + uuid4().hex, 'retained' + uuid4().hex]
|
||||
with tempfile.TemporaryDirectory(prefix='resource-documents-', dir=suite.output) as directory:
|
||||
for index, marker in enumerate(markers):
|
||||
document = Path(directory) / f'document-{index}.txt'
|
||||
document.write_text(f'Lifecycle fixture: {marker}. This is document {index}.\n')
|
||||
previous = {item['uuid'] for item in files()}
|
||||
suite.call(
|
||||
[
|
||||
'knowledge-base',
|
||||
'ingest',
|
||||
kb_uuid,
|
||||
'--file',
|
||||
str(document),
|
||||
'--parser-plugin-id',
|
||||
'langbot-team/GeneralParsers',
|
||||
'--wait',
|
||||
]
|
||||
)
|
||||
added = [item for item in files() if item['uuid'] not in previous]
|
||||
require(
|
||||
len(added) == 1 and added[0].get('status') == 'completed',
|
||||
'Document ingestion did not create exactly one completed file',
|
||||
)
|
||||
documents.append(added[0])
|
||||
owned['files'].append({'uuid': added[0]['uuid'], 'status': added[0]['status']})
|
||||
journal(suite, row)
|
||||
initial = retrieve(' '.join(markers))
|
||||
checked(
|
||||
row,
|
||||
'retrieval_initial',
|
||||
lambda: require(
|
||||
all(contains(initial, document, marker) for document, marker in zip(documents, markers)),
|
||||
'Both fixture documents must be independently retrievable before deletion',
|
||||
),
|
||||
)
|
||||
data(suite, 'DELETE', path + '/files/' + documents[0]['uuid'])
|
||||
after = retrieve(' '.join(markers))
|
||||
checked(
|
||||
row,
|
||||
'retrieval_deleted',
|
||||
lambda: require(
|
||||
all(
|
||||
item.get('metadata', {}).get('file_id') != documents[0]['engine_document_id']
|
||||
and markers[0] not in json.dumps(item, ensure_ascii=False)
|
||||
for item in after
|
||||
)
|
||||
and documents[0]['uuid'] not in {item['uuid'] for item in files()},
|
||||
'Deleted document remains visible in retrieval or the file list',
|
||||
),
|
||||
)
|
||||
checked(
|
||||
row,
|
||||
'retrieval_control',
|
||||
lambda: require(
|
||||
contains(after, documents[1], markers[1]),
|
||||
'Deleting one document removed the surviving control document',
|
||||
),
|
||||
)
|
||||
return {
|
||||
'result': case['expect'],
|
||||
'deleted_document_id': documents[0]['uuid'],
|
||||
'control_document_id': documents[1]['uuid'],
|
||||
}
|
||||
finally:
|
||||
cleanup(suite, row, 'document_cleanup', cleanup_documents)
|
||||
|
||||
|
||||
def run_case(suite, case, row):
|
||||
actions = {
|
||||
'auth_revocation': auth_revocation,
|
||||
'agent_direct_execution': agent_direct_execution,
|
||||
'retrieval_delete_visibility': retrieval_delete_visibility,
|
||||
}
|
||||
return actions[case['scenario']](suite, case, row)
|
||||
@@ -11,7 +11,7 @@ ROOT = Path(__file__).parent
|
||||
RUNNER = 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
|
||||
def expanded_profile():
|
||||
def expanded_profile(groups=()):
|
||||
profile = json.loads((ROOT / 'regression_profiles/smoke.json').read_text())
|
||||
profile['coverage_suite'] = 'scenarios'
|
||||
profile['smoke']['group_turns'] = 3
|
||||
@@ -102,6 +102,10 @@ def expanded_profile():
|
||||
expect='LANGBOT_' + name.upper() + '_OK',
|
||||
)
|
||||
profile['cases'].append(case)
|
||||
if groups:
|
||||
from regression_local_cases import add_cases
|
||||
|
||||
add_cases(profile, groups)
|
||||
return profile
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from uuid import uuid4
|
||||
|
||||
from regression_traffic import group_request, percentile, read_endpoint_snapshot, reconcile_runs
|
||||
from regression_coverage import checked, check_response
|
||||
from regression_local_cases import ScenarioUnavailable
|
||||
|
||||
|
||||
class UnresolvedRun(RuntimeError):
|
||||
@@ -316,7 +317,11 @@ def run_cycle(suite, rows, health, cycle):
|
||||
token = uuid4().hex
|
||||
offset = mock_path.stat().st_size if case.get('mock_requests') and mock_path.exists() else 0
|
||||
try:
|
||||
if rejected:
|
||||
if 'scenario' in case:
|
||||
from regression_local_cases import run_case
|
||||
|
||||
data = run_case(suite, case, row)
|
||||
elif rejected:
|
||||
data = checked(
|
||||
row,
|
||||
'response',
|
||||
@@ -358,7 +363,7 @@ def run_cycle(suite, rows, health, cycle):
|
||||
row['output'] = data
|
||||
if case.get('forbid'):
|
||||
checked(row, 'isolation', lambda: check_response(data, case['expect'], case['forbid']))
|
||||
row['checks'] = {}
|
||||
row.setdefault('checks', {})
|
||||
if case.get('fields'):
|
||||
row['checks']['fields'] = checked(row, 'fields', lambda: check_fields(data, case['fields']))
|
||||
if case.get('attachment'):
|
||||
@@ -392,6 +397,8 @@ def run_cycle(suite, rows, health, cycle):
|
||||
row['status'] = 'passed'
|
||||
previous[group] = token
|
||||
history_turns[group] += 1
|
||||
except ScenarioUnavailable as exc:
|
||||
row.update(status='skipped', error=redact(str(exc)))
|
||||
except urllib.error.HTTPError as exc:
|
||||
row.update(
|
||||
status='unknown' if exc.code >= 500 else 'failed', error=f'HTTP {exc.code}; not retried'
|
||||
@@ -413,6 +420,8 @@ def run_cycle(suite, rows, health, cycle):
|
||||
if 'timed out' in message:
|
||||
row['timed_out'] = True
|
||||
row['seconds'] = time.monotonic() - before
|
||||
for error in row.get('cleanup_errors', []):
|
||||
errors.append(f'{case["name"]}: cleanup failed: {redact(str(error))}')
|
||||
if row['status'] != 'passed':
|
||||
errors.append(f'{case["name"]}: {row["status"]}: {row["error"]}')
|
||||
if is_group:
|
||||
@@ -462,7 +471,7 @@ def run_smoke(suite):
|
||||
}
|
||||
)
|
||||
suite.write('cycles.json', cycles)
|
||||
if not execution or not execution.admitting() or any(row['status'] == 'unknown' for row in rows):
|
||||
if not execution or not execution.admitting() or any(row['status'] in ('unknown', 'skipped') for row in rows):
|
||||
break
|
||||
result['errors'] = errors
|
||||
result['cycles'] = cycles
|
||||
@@ -470,7 +479,7 @@ def run_smoke(suite):
|
||||
result['incomplete'] = [
|
||||
f'{row["case"]}: {row["status"]}: {row["error"]}'
|
||||
for row in rows
|
||||
if row['status'] == 'unknown' and not row.get('timed_out')
|
||||
if row['status'] in ('unknown', 'skipped') and not row.get('timed_out')
|
||||
]
|
||||
if not result['first_pass_complete']:
|
||||
error = 'Required first pass did not finish before the admission deadline'
|
||||
@@ -486,7 +495,7 @@ def run_smoke(suite):
|
||||
for name in dict.fromkeys(row['case'] for row in rows)
|
||||
},
|
||||
}
|
||||
executed = [row for row in rows if row.get('requires_runner') and 'after_id' in row]
|
||||
executed = scenario_run_rows(rows)
|
||||
result['test_data'] = {
|
||||
'observed_conversations': len({row['conversation_id'] for row in executed if 'conversation_id' in row}),
|
||||
'observed_agent_runs': len({row['run']['run_id'] for row in executed if 'run' in row}),
|
||||
@@ -497,9 +506,24 @@ def run_smoke(suite):
|
||||
return result
|
||||
|
||||
|
||||
def scenario_run_rows(rows):
|
||||
executed = [row for row in rows if row.get('requires_runner') and 'after_id' in row]
|
||||
for row in rows:
|
||||
for run in row.get('scenario_runs', []):
|
||||
executed.append(
|
||||
{
|
||||
'after_id': row['after_id'],
|
||||
'conversation_id': run['conversation_id'],
|
||||
'run': {'run_id': run['run_id']},
|
||||
'expected_status': run['expected_status'],
|
||||
}
|
||||
)
|
||||
return executed
|
||||
|
||||
|
||||
def reconcile_scenario_runs(suite, result, *, key='ledger'):
|
||||
"""Recheck the originally observed conversations without expanding run ownership."""
|
||||
executed = [row for row in result.get('cases', []) if row.get('requires_runner') and 'after_id' in row]
|
||||
executed = scenario_run_rows(result.get('cases', []))
|
||||
if not executed:
|
||||
return
|
||||
try:
|
||||
@@ -509,6 +533,9 @@ def reconcile_scenario_runs(suite, result, *, key='ledger'):
|
||||
min(row['after_id'] for row in executed),
|
||||
{row['conversation_id'] for row in executed if 'conversation_id' in row},
|
||||
{row['run']['run_id'] for row in executed if 'run' in row},
|
||||
expected_statuses={
|
||||
row['run']['run_id']: row.get('expected_status', 'completed') for row in executed if 'run' in row
|
||||
},
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
error = f'Scenario {key} evidence unavailable: {exc}'
|
||||
@@ -516,5 +543,5 @@ def reconcile_scenario_runs(suite, result, *, key='ledger'):
|
||||
result.setdefault('incomplete', []).append(error)
|
||||
return
|
||||
if not result[key]['matched']:
|
||||
error = f'Scenario {key} found extra, missing or non-completed AgentRuns'
|
||||
error = f'Scenario {key} found extra, missing or unexpected AgentRun states'
|
||||
result['errors'].append(error)
|
||||
|
||||
@@ -83,6 +83,7 @@ def validate(profile):
|
||||
'coverage_suite',
|
||||
'execution',
|
||||
'performance_mode',
|
||||
'scenario_groups',
|
||||
}
|
||||
if extra := profile.keys() - allowed:
|
||||
raise ValueError(f'unknown profile fields: {sorted(extra)}')
|
||||
@@ -98,6 +99,16 @@ def validate(profile):
|
||||
raise ValueError('coverage_suite must be a stable suite identifier')
|
||||
if profile.get('performance_mode', 'gate') not in {'gate', 'observe'}:
|
||||
raise ValueError('performance_mode must be gate or observe')
|
||||
if 'scenario_groups' in profile:
|
||||
from regression_local_cases import GROUPS
|
||||
|
||||
groups = profile['scenario_groups']
|
||||
if (
|
||||
not isinstance(groups, list)
|
||||
or any(not isinstance(name, str) or name not in GROUPS for name in groups)
|
||||
or len(groups) != len(set(groups))
|
||||
):
|
||||
raise ValueError('scenario_groups must contain unique supported local groups')
|
||||
execution = profile.get('execution')
|
||||
if execution is not None:
|
||||
if (
|
||||
@@ -192,6 +203,7 @@ def validate(profile):
|
||||
'reject_group',
|
||||
'http_probe',
|
||||
'reset_group',
|
||||
'scenario',
|
||||
}:
|
||||
raise ValueError('unknown case field')
|
||||
if not isinstance(case.get('name'), str) or not case['name'] or case['name'] in names:
|
||||
@@ -250,7 +262,21 @@ def validate(profile):
|
||||
case['reset_group'] is not True or 'http_group' not in case or 'reject_group' in case
|
||||
):
|
||||
raise ValueError('reset_group requires an accepted group case')
|
||||
if 'http_probe' in case:
|
||||
if 'scenario' in case:
|
||||
from regression_local_cases import modules
|
||||
|
||||
handlers = modules()
|
||||
if (
|
||||
'smoke' not in profile
|
||||
or not isinstance(case['scenario'], str)
|
||||
or not any(
|
||||
case['scenario'] in module.CASE_NAMES and name in profile.get('scenario_groups', [])
|
||||
for name, module in handlers.items()
|
||||
)
|
||||
or any(key in case for key in ('args', 'body', 'http_probe', 'http_group', 'box_exec', 'attachment'))
|
||||
):
|
||||
raise ValueError('scenario must name a supported finite local case without another request mode')
|
||||
elif 'http_probe' in case:
|
||||
probe = case['http_probe']
|
||||
if (
|
||||
not isinstance(probe, dict)
|
||||
|
||||
@@ -131,9 +131,14 @@ def group_request(case, group_id, member_id, turn, token, previous, env=None, *,
|
||||
return payload
|
||||
|
||||
|
||||
def reconcile_runs(path, workspace, after_id, conversations, expected_ids):
|
||||
def reconcile_runs(path, workspace, after_id, conversations, expected_ids, *, expected_statuses=None):
|
||||
"""Catch delayed extra or nonterminal runs after request-level verification."""
|
||||
actual, unfinished = set(), []
|
||||
expected_statuses = expected_statuses or {}
|
||||
if set(expected_statuses) - set(expected_ids) or any(
|
||||
status not in {'completed', 'failed', 'cancelled', 'timeout'} for status in expected_statuses.values()
|
||||
):
|
||||
raise ValueError('Expected statuses must name owned runs and terminal states')
|
||||
connection = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True)
|
||||
try:
|
||||
values = sorted(conversations)
|
||||
@@ -145,7 +150,7 @@ def reconcile_runs(path, workspace, after_id, conversations, expected_ids):
|
||||
)
|
||||
for run_id, status in connection.execute(query, [workspace, after_id, *part]):
|
||||
actual.add(run_id)
|
||||
if status != 'completed':
|
||||
if status != expected_statuses.get(run_id, 'completed'):
|
||||
unfinished.append(run_id)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -291,8 +291,13 @@ class Routes:
|
||||
path = Path(__file__).with_name('regression_profiles') / f'{name}.json'
|
||||
if name == 'scenarios':
|
||||
from regression_scenarios import expanded_profile
|
||||
from regression_local_cases import GROUPS
|
||||
|
||||
profile = expanded_profile()
|
||||
profile = expanded_profile(GROUPS)
|
||||
if 'lifecycle' in GROUPS:
|
||||
from regression_lifecycle_cases import restart_profile
|
||||
|
||||
profile['cases'] += restart_profile(profile)['cases']
|
||||
else:
|
||||
profile = json.loads(path.read_text())
|
||||
profiles[name] = {x['name']: x for x in profile['cases']}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Keep optional cases and expected failures from turning incomplete evidence green."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from regression_local_cases import GROUPS, ScenarioUnavailable
|
||||
from regression_scenarios import expanded_profile
|
||||
from regression_smoke import run_smoke
|
||||
from regression_suite import Suite, validate
|
||||
from regression_traffic import reconcile_runs
|
||||
|
||||
|
||||
class LocalScenarioTests(unittest.TestCase):
|
||||
def test_group_switches_keep_base_cases_and_reject_unknown_handlers(self):
|
||||
base = expanded_profile()
|
||||
for group in GROUPS:
|
||||
profile = expanded_profile([group])
|
||||
validate(profile)
|
||||
self.assertEqual(profile['scenario_groups'], [group])
|
||||
self.assertGreater(len(profile['cases']), len(base['cases']))
|
||||
self.assertEqual(profile['cases'][: len(base['cases'])], base['cases'])
|
||||
for groups in (['unknown'], ['resources', 'resources']):
|
||||
with self.assertRaises(ValueError):
|
||||
expanded_profile(groups)
|
||||
profile = expanded_profile(['resources'])
|
||||
profile['scenario_groups'] = []
|
||||
with self.assertRaises(ValueError):
|
||||
validate(profile)
|
||||
profile['scenario_groups'] = ['resources']
|
||||
profile['cases'][-1]['scenario'] = 'not-a-real-handler'
|
||||
with self.assertRaises(ValueError):
|
||||
validate(profile)
|
||||
|
||||
def test_missing_prerequisite_is_skipped_and_incomplete(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with sqlite3.connect(root / 'ledger.db') as connection:
|
||||
connection.execute('CREATE TABLE agent_run (id INTEGER)')
|
||||
profile = {
|
||||
'version': 1,
|
||||
'cli': {'config': 'context.yaml'},
|
||||
'smoke': {'ledger_db': 'ledger.db'},
|
||||
'cases': [{'name': 'auth_revocation', 'scenario': 'auth_revocation', 'expect': 'OK'}],
|
||||
'observe': {'health': {}},
|
||||
}
|
||||
suite = Suite(profile, root / 'profile.json', root)
|
||||
with patch('regression_local_cases.run_case', side_effect=ScenarioUnavailable('Missing user credential')):
|
||||
result = run_smoke(suite)
|
||||
self.assertEqual(result['status'], {'skipped': 1})
|
||||
self.assertEqual(result['errors'], result['incomplete'])
|
||||
self.assertIn('Missing user credential', result['incomplete'][0])
|
||||
self.assertFalse(result['cases'][0].get('assertions'))
|
||||
self.assertEqual(json.loads((root / 'cases.json').read_text())[0]['status'], 'skipped')
|
||||
|
||||
def test_cleanup_failure_remains_fail_after_unknown_request(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with sqlite3.connect(root / 'ledger.db') as connection:
|
||||
connection.execute('CREATE TABLE agent_run (id INTEGER)')
|
||||
profile = {
|
||||
'version': 1,
|
||||
'cli': {'config': 'context.yaml'},
|
||||
'smoke': {'ledger_db': 'ledger.db'},
|
||||
'cases': [{'name': 'agent_direct_execution', 'scenario': 'agent_direct_execution', 'expect': 'OK'}],
|
||||
'observe': {'health': {}},
|
||||
}
|
||||
suite = Suite(profile, root / 'profile.json', root)
|
||||
|
||||
def fail(suite, case, row):
|
||||
row['cleanup_errors'] = ['Agent deletion returned HTTP 500']
|
||||
raise RuntimeError('Request outcome unknown')
|
||||
|
||||
with patch('regression_local_cases.run_case', side_effect=fail):
|
||||
result = run_smoke(suite)
|
||||
suite.finish(result)
|
||||
self.assertEqual(result['outcome'], 'FAIL')
|
||||
self.assertEqual(result['cases'][0]['status'], 'unknown')
|
||||
self.assertTrue(any('cleanup failed' in message for message in result['errors']))
|
||||
|
||||
def test_expected_failure_does_not_allow_extra_or_running_runs(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'ledger.db'
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
'CREATE TABLE agent_run (id INTEGER, run_id TEXT, workspace_id TEXT, conversation_id TEXT, status TEXT)'
|
||||
)
|
||||
connection.execute("INSERT INTO agent_run VALUES (1, 'owned', 'ws', 'session', 'failed')")
|
||||
args = (path, 'ws', 0, {'session'}, {'owned'})
|
||||
self.assertFalse(reconcile_runs(*args)['matched'])
|
||||
expected = {'owned': 'failed'}
|
||||
self.assertTrue(reconcile_runs(*args, expected_statuses=expected)['matched'])
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute("UPDATE agent_run SET status='running'")
|
||||
self.assertFalse(reconcile_runs(*args, expected_statuses=expected)['matched'])
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute("UPDATE agent_run SET status='failed'")
|
||||
connection.execute("INSERT INTO agent_run VALUES (2, 'extra', 'ws', 'session', 'completed')")
|
||||
result = reconcile_runs(*args, expected_statuses=expected)
|
||||
self.assertFalse(result['matched'])
|
||||
self.assertEqual(result['extra'], ['extra'])
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile_runs(*args, expected_statuses={'owned': 'running'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Challenge public resource lifecycle oracles and their cleanup without starting services."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from regression_local_cases import ScenarioUnavailable
|
||||
from regression_resource_cases import run_case
|
||||
|
||||
|
||||
def success(data=None):
|
||||
return 200, {'code': 0, 'data': data or {}}
|
||||
|
||||
|
||||
class ResourceCasesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.suite = SimpleNamespace(
|
||||
output=self.root,
|
||||
env={'LANGBOT_QA_USER_TOKEN': 'private-user-token'},
|
||||
secrets=[],
|
||||
variables={'workspace_uuid': 'workspace', 'resource_knowledge_base': 'owned-kb'},
|
||||
profile={
|
||||
'resources': [
|
||||
{
|
||||
'id': 'chat_pipeline',
|
||||
'body': {'config': {'ai': {'runner': {'id': 'runner'}, 'runner_config': {'runner': {}}}}},
|
||||
}
|
||||
]
|
||||
},
|
||||
write=lambda name, value: (self.root / name).write_text(json.dumps(value)),
|
||||
)
|
||||
|
||||
def test_missing_management_session_skips_without_creating_credentials(self):
|
||||
self.suite.env.clear()
|
||||
with patch('regression_resource_cases.api') as request, self.assertRaises(ScenarioUnavailable):
|
||||
run_case(self.suite, {'scenario': 'auth_revocation'}, {})
|
||||
request.assert_not_called()
|
||||
|
||||
def test_revocation_rejects_still_authorized_key_and_never_persists_secrets(self):
|
||||
for stale_authorization in (False, True):
|
||||
with self.subTest(stale_authorization=stale_authorization):
|
||||
key = {'id': 7, 'uuid': 'managed-key', 'status': 'active', 'secret_available': False}
|
||||
row = {'after_id': 0}
|
||||
|
||||
def request(suite, method, path, body=None, headers=None):
|
||||
if method == 'POST':
|
||||
return success({'key': {**key, 'key': 'private-child-secret', 'secret_available': True}})
|
||||
if method == 'DELETE':
|
||||
key['status'] = 'revoked'
|
||||
return success()
|
||||
if path == '/api/v1/system/context':
|
||||
self.assertEqual(headers, {'X-API-Key': 'private-child-secret'})
|
||||
return success({'workspace_uuid': 'workspace', 'api_key_id': 'managed-key'})
|
||||
if path == '/api/v1/pipelines':
|
||||
return (
|
||||
success({'pipelines': []})
|
||||
if key['status'] == 'active' or stale_authorization
|
||||
else (401, {'code': -1})
|
||||
)
|
||||
return success({'key': key})
|
||||
|
||||
with patch('regression_resource_cases.api', side_effect=request):
|
||||
case = {'scenario': 'auth_revocation', 'expect': 'REVOKED'}
|
||||
if stale_authorization:
|
||||
with self.assertRaisesRegex(ValueError, 'still authorizes'):
|
||||
run_case(self.suite, case, row)
|
||||
self.assertEqual(row['assertions']['credential_revoked'], 'failed')
|
||||
else:
|
||||
self.assertEqual(run_case(self.suite, case, row)['status'], 'revoked')
|
||||
self.assertEqual(key['status'], 'revoked')
|
||||
self.assertEqual(row['assertions']['key_cleanup'], 'passed')
|
||||
evidence = json.dumps(row) + (self.root / row['resource_journal']).read_text()
|
||||
self.assertNotIn('private-child-secret', evidence)
|
||||
self.assertNotIn('private-user-token', evidence)
|
||||
|
||||
def test_direct_agent_records_run_and_cleans_after_failed_public_event_oracle(self):
|
||||
for missing_event in (False, True):
|
||||
with self.subTest(missing_event=missing_event):
|
||||
state, row = {}, {'after_id': 12}
|
||||
|
||||
def request(suite, method, path, body=None, headers=None):
|
||||
if method == 'POST' and path == '/api/v1/agents':
|
||||
state.update(uuid='own-agent', name=body['name'])
|
||||
return success({'uuid': 'own-agent'})
|
||||
if path.endswith('/debug'):
|
||||
state['conversation'] = body['conversation_id']
|
||||
return success({'conversation_id': state['conversation'], 'final_text': 'DIRECT_OK'})
|
||||
run = {
|
||||
'run_id': 'own-run',
|
||||
'conversation_id': state.get('conversation'),
|
||||
'status': 'completed',
|
||||
'finished_at': 1,
|
||||
}
|
||||
if path.endswith('/runs'):
|
||||
return success({'items': [run]})
|
||||
if path.endswith('/events'):
|
||||
return success(
|
||||
{
|
||||
'run': run,
|
||||
'items': [] if missing_event else [{'type': 'run.completed', 'run_id': 'own-run'}],
|
||||
}
|
||||
)
|
||||
if method == 'DELETE':
|
||||
self.assertEqual(path, '/api/v1/agents/own-agent')
|
||||
state['deleted'] = True
|
||||
return success()
|
||||
return (404, {'code': -1}) if state.get('deleted') else success({'agent': state})
|
||||
|
||||
with patch('regression_resource_cases.api', side_effect=request):
|
||||
case = {'scenario': 'agent_direct_execution', 'expect': 'DIRECT_OK'}
|
||||
if missing_event:
|
||||
with self.assertRaisesRegex(ValueError, 'completion event'):
|
||||
run_case(self.suite, case, row)
|
||||
else:
|
||||
self.assertEqual(run_case(self.suite, case, row)['run_id'], 'own-run')
|
||||
self.assertEqual(
|
||||
row['scenario_runs'],
|
||||
[
|
||||
{
|
||||
'run_id': 'own-run',
|
||||
'conversation_id': state['conversation'],
|
||||
'after_id': 12,
|
||||
'expected_status': 'completed',
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertTrue(state['deleted'])
|
||||
self.assertEqual(row['assertions']['agent_cleanup'], 'passed')
|
||||
|
||||
def test_unknown_agent_create_recovers_owned_identity_without_retrying_creation(self):
|
||||
calls, state, row = [], {}, {'after_id': 0}
|
||||
|
||||
def request(suite, method, path, body=None, headers=None):
|
||||
calls.append((method, path))
|
||||
if method == 'POST':
|
||||
state.update(uuid='own-agent', name=body['name'], kind='agent')
|
||||
raise ConnectionResetError('creation response lost')
|
||||
if path == '/api/v1/agents':
|
||||
return success({'agents': [state, {'uuid': 'unrelated', 'name': 'other', 'kind': 'agent'}]})
|
||||
if method == 'DELETE':
|
||||
self.assertEqual(path, '/api/v1/agents/own-agent')
|
||||
state['deleted'] = True
|
||||
return success()
|
||||
return (404, {'code': -1}) if state.get('deleted') else success({'agent': state})
|
||||
|
||||
with patch('regression_resource_cases.api', side_effect=request), self.assertRaises(ConnectionResetError):
|
||||
run_case(self.suite, {'scenario': 'agent_direct_execution', 'expect': 'OK'}, row)
|
||||
self.assertEqual(sum(method == 'POST' for method, _ in calls), 1)
|
||||
self.assertEqual(row['owned_resources'][0]['state'], 'deleted')
|
||||
|
||||
def test_delete_requires_retrieval_absence_and_preserves_control_document(self):
|
||||
for stale_vectors in (False, True):
|
||||
with self.subTest(stale_vectors=stale_vectors):
|
||||
documents, row = [], {'after_id': 0}
|
||||
|
||||
def ingest(args):
|
||||
content = Path(args[args.index('--file') + 1]).read_text()
|
||||
index = len(documents)
|
||||
documents.append(
|
||||
{
|
||||
'uuid': f'file-{index}',
|
||||
'engine_document_id': f'engine-{index}',
|
||||
'status': 'completed',
|
||||
'content': content,
|
||||
'deleted': False,
|
||||
}
|
||||
)
|
||||
return {}
|
||||
|
||||
def request(suite, method, path, body=None, headers=None):
|
||||
if method == 'DELETE':
|
||||
target = next(document for document in documents if path.endswith('/' + document['uuid']))
|
||||
target['deleted'] = True
|
||||
return success()
|
||||
if method == 'GET':
|
||||
return success({'files': [document for document in documents if not document['deleted']]})
|
||||
return success(
|
||||
{
|
||||
'results': [
|
||||
{
|
||||
'metadata': {'file_id': document['engine_document_id']},
|
||||
'content': [{'type': 'text', 'text': document['content']}],
|
||||
}
|
||||
for document in documents
|
||||
if not document['deleted'] or stale_vectors
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.suite.call = ingest
|
||||
with patch('regression_resource_cases.api', side_effect=request):
|
||||
case = {'scenario': 'retrieval_delete_visibility', 'expect': 'DELETED'}
|
||||
if stale_vectors:
|
||||
with self.assertRaisesRegex(ValueError, 'remains visible'):
|
||||
run_case(self.suite, case, row)
|
||||
self.assertEqual(row['assertions']['retrieval_deleted'], 'failed')
|
||||
else:
|
||||
self.assertEqual(run_case(self.suite, case, row)['result'], 'DELETED')
|
||||
self.assertEqual(row['assertions']['retrieval_control'], 'passed')
|
||||
self.assertTrue(all(document['deleted'] for document in documents))
|
||||
self.assertEqual(row['assertions']['document_cleanup'], 'passed')
|
||||
self.assertEqual(list(self.root.glob('resource-documents-*')), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user