051ee7c290
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 47s
CI / security (pull_request) Successful in 52s
CI / build (pull_request) Successful in 56s
CI / e2e_tests (pull_request) Successful in 5m1s
CI / integration_tests (pull_request) Successful in 5m30s
CI / unit_tests (pull_request) Successful in 5m42s
CI / docker (pull_request) Successful in 58s
CI / coverage (pull_request) Successful in 7m35s
CI / build (push) Successful in 21s
CI / docker (push) Has been skipped
CI / benchmark-regression (pull_request) Failing after 49m24s
CI / lint (push) Successful in 22s
CI / quality (push) Successful in 39s
CI / security (push) Successful in 48s
CI / typecheck (push) Successful in 1m26s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 5m53s
CI / coverage (push) Successful in 9m4s
CI / benchmark-publish (push) Successful in 19m10s
CI / integration_tests (push) Failing after 19m18s
CI / unit_tests (push) Failing after 19m20s
Added 52 new .feature files and corresponding _steps.py files targeting previously uncovered code paths in the following areas: - TUI layer: app, commands, persona (state/schema/registry), widgets, input (shell_exec, reference_parser) - Application services: plan lifecycle/service/executor, session, project, repo indexing, correction, checkpoint, actor, llm_actors, strategy coordinator, resource file watcher, service retry wiring - CLI commands: session, resource, repl, plan, db, automation_profile - Domain models: retry_policy, resource_type, cost_budget, docker_compose_analyzer, detail_level, _sql_string_aware, _postgresql_helpers - Core: circuit_breaker, retry_service_patterns - Infrastructure: repositories, transaction_sandbox, strategy_registry, plugins/loader, container - Config: settings - Agents: plan_generation, context_analysis, auto_debug - A2A: facade All new tests follow the Behave/Gherkin BDD standard. Resolved step definition collisions with unique prefixes. Fixed Alembic fileConfig logger disabling issue (disable_existing_loggers=False). ISSUES CLOSED: #1068
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""Step definitions for postgresql_helpers_coverage.feature.
|
|
|
|
These steps target specific uncovered lines in _postgresql_helpers.py:
|
|
- Line 50: ident_pair with quoted identifier containing escaped double-quotes
|
|
- Line 192: _skip_quoted with escaped quotes inside strings (j += 2)
|
|
- Line 197: _skip_quoted with unclosed quote (return n)
|
|
- Lines 198-204: _skip_quoted with dollar-quoting ($$ ... $$)
|
|
- Line 215: extract_body with invalid paren_start
|
|
- Line 230: extract_body with unbalanced parentheses
|
|
- Lines 384-385, 387-388: extract_fk_triples column count mismatch warning
|
|
- Line 434: parse_table_body empty entry continue
|
|
"""
|
|
|
|
import logging
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.domain.models.acms._postgresql_helpers import (
|
|
CREATE_TABLE_RE,
|
|
FOREIGN_KEY_RE,
|
|
_skip_quoted,
|
|
extract_body,
|
|
extract_fk_triples,
|
|
ident_pair,
|
|
parse_table_body,
|
|
split_entries,
|
|
table_uri,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the postgresql helpers module is imported")
|
|
def step_module_imported(context):
|
|
"""Ensure the module is importable."""
|
|
assert extract_body is not None
|
|
assert split_entries is not None
|
|
assert ident_pair is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ident_pair: quoted identifier with escaped double-quotes (line 50)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a regex match with a quoted identifier containing escaped double-quotes")
|
|
def step_quoted_ident_match(context):
|
|
"""Create a regex match where the quoted branch has double-double-quotes.
|
|
|
|
We use CREATE_TABLE_RE against a CREATE TABLE statement with a quoted
|
|
schema name containing escaped double-quotes: "my""schema"."table"
|
|
"""
|
|
# _IDENT produces two groups each: (quoted, unquoted)
|
|
# CREATE_TABLE_RE groups: 1,2 = schema; 3,4 = table
|
|
sql = 'CREATE TABLE "my""schema"."table" ('
|
|
m = CREATE_TABLE_RE.search(sql)
|
|
assert m is not None, f"CREATE_TABLE_RE did not match: {sql!r}"
|
|
context.ident_match = m
|
|
|
|
|
|
@when("I call ident_pair on the quoted match")
|
|
def step_call_ident_pair_quoted(context):
|
|
"""Call ident_pair targeting the quoted schema groups."""
|
|
# Groups 1,2 are the schema pair in CREATE_TABLE_RE
|
|
context.ident_result = ident_pair(context.ident_match, 1, 2)
|
|
|
|
|
|
@then("the result should have double-quotes unescaped")
|
|
def step_verify_unescaped(context):
|
|
"""Verify that '""' was replaced with '"'."""
|
|
assert context.ident_result == 'my"schema', (
|
|
f"Expected 'my\"schema', got {context.ident_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _skip_quoted: escaped single-quotes (line 192)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("SQL content with parenthesized body containing escaped single-quotes")
|
|
def step_content_escaped_single_quotes(context):
|
|
"""Build content with a string containing '' (escaped single-quote).
|
|
|
|
E.g.: (col1 TEXT DEFAULT 'it''s fine', col2 INT)
|
|
The '' inside the string should be skipped by _skip_quoted.
|
|
"""
|
|
context.paren_content = "(col1 TEXT DEFAULT 'it''s fine', col2 INT)"
|
|
context.paren_start = 0
|
|
|
|
|
|
@when("I call extract_body on the content")
|
|
def step_call_extract_body(context):
|
|
"""Call extract_body with the content."""
|
|
context.extracted_body = extract_body(context.paren_content, context.paren_start)
|
|
|
|
|
|
@then("the body should be extracted correctly with the quoted string intact")
|
|
def step_verify_body_with_quotes(context):
|
|
"""Verify the body was extracted including the full quoted string."""
|
|
assert "col1 TEXT DEFAULT 'it''s fine'" in context.extracted_body, (
|
|
f"Body missing escaped-quote string: {context.extracted_body!r}"
|
|
)
|
|
assert "col2 INT" in context.extracted_body
|
|
|
|
|
|
@given("a table body with escaped double-quotes in an identifier")
|
|
def step_body_with_escaped_double_quotes(context):
|
|
"""Build a body containing a double-quoted identifier with "".
|
|
|
|
E.g.: "col""name" TEXT, col2 INT
|
|
"""
|
|
context.split_body = '"col""name" TEXT, col2 INT'
|
|
|
|
|
|
@when("I call split_entries on the body")
|
|
def step_call_split_entries(context):
|
|
"""Call split_entries."""
|
|
context.split_result = split_entries(context.split_body)
|
|
|
|
|
|
@then("the entries should be split correctly preserving the identifier")
|
|
def step_verify_split_with_double_quotes(context):
|
|
"""Verify entries are split at depth-0 commas only."""
|
|
assert len(context.split_result) == 2, (
|
|
f"Expected 2 entries, got {len(context.split_result)}: {context.split_result}"
|
|
)
|
|
assert '"col""name" TEXT' in context.split_result[0]
|
|
assert "col2 INT" in context.split_result[1].strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _skip_quoted: unclosed quote (line 197)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("SQL content with an unclosed single-quote inside parentheses")
|
|
def step_content_unclosed_quote(context):
|
|
"""Build content with an unclosed single-quote.
|
|
|
|
The _skip_quoted function should advance to end-of-string (return n).
|
|
Because the quote is never closed, the parenthesis balance is broken,
|
|
so extract_body returns "".
|
|
"""
|
|
context.unclosed_content = "(col1 TEXT DEFAULT 'unclosed"
|
|
context.unclosed_start = 0
|
|
|
|
|
|
@when("I call extract_body on the unclosed-quote content")
|
|
def step_call_extract_body_unclosed(context):
|
|
"""Call extract_body on content with unclosed quote."""
|
|
context.unclosed_result = extract_body(
|
|
context.unclosed_content, context.unclosed_start
|
|
)
|
|
|
|
|
|
@then("extract_body should return an empty string for unbalanced parens")
|
|
def step_verify_unclosed_quote_empty(context):
|
|
"""When a quote is never closed the body can't be balanced."""
|
|
assert context.unclosed_result == "", (
|
|
f"Expected empty string, got {context.unclosed_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _skip_quoted: dollar-quoting (lines 198-203)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("SQL content with a dollar-quoted string inside parentheses")
|
|
def step_content_dollar_quoted(context):
|
|
"""Build content with a $$...$$ dollar-quoted string.
|
|
|
|
E.g.: (col1 TEXT DEFAULT $$hello, world$$, col2 INT)
|
|
"""
|
|
context.dollar_content = "(col1 TEXT DEFAULT $$hello, world$$, col2 INT)"
|
|
context.dollar_start = 0
|
|
|
|
|
|
@when("I call extract_body on the dollar-quoted content")
|
|
def step_call_extract_body_dollar(context):
|
|
"""Call extract_body."""
|
|
context.dollar_result = extract_body(context.dollar_content, context.dollar_start)
|
|
|
|
|
|
@then("the body should be extracted correctly ignoring dollar-quoted content")
|
|
def step_verify_dollar_body(context):
|
|
"""The body should contain both columns."""
|
|
assert "col1 TEXT" in context.dollar_result
|
|
assert "col2 INT" in context.dollar_result
|
|
|
|
|
|
@given("a table body containing a dollar-quoted string with commas")
|
|
def step_dollar_body_with_commas(context):
|
|
"""A body where a dollar-quoted string contains commas that should be ignored."""
|
|
context.dollar_split_body = "col1 TEXT DEFAULT $$a,b,c$$, col2 INT"
|
|
|
|
|
|
@when("I call split_entries on the dollar-quoted body")
|
|
def step_call_split_entries_dollar(context):
|
|
"""Call split_entries."""
|
|
context.dollar_split_result = split_entries(context.dollar_split_body)
|
|
|
|
|
|
@then("the comma inside the dollar-quoted string should not split entries")
|
|
def step_verify_dollar_split(context):
|
|
"""Only 2 entries — the commas inside $$ should be ignored."""
|
|
assert len(context.dollar_split_result) == 2, (
|
|
f"Expected 2 entries, got {len(context.dollar_split_result)}: "
|
|
f"{context.dollar_split_result}"
|
|
)
|
|
|
|
|
|
@given("SQL content with an unclosed dollar-quoted string")
|
|
def step_content_unclosed_dollar(context):
|
|
"""Build content with $$ that is never closed.
|
|
|
|
This exercises the `else n` branch at line 203.
|
|
"""
|
|
context.unclosed_dollar_content = "(col1 TEXT DEFAULT $$unclosed body"
|
|
context.unclosed_dollar_start = 0
|
|
|
|
|
|
@when("I call extract_body on the unclosed dollar-quote content")
|
|
def step_call_extract_body_unclosed_dollar(context):
|
|
"""Call extract_body."""
|
|
context.unclosed_dollar_result = extract_body(
|
|
context.unclosed_dollar_content, context.unclosed_dollar_start
|
|
)
|
|
|
|
|
|
@then("extract_body should handle the unclosed dollar-quote gracefully")
|
|
def step_verify_unclosed_dollar(context):
|
|
"""With unclosed dollar-quote, parens can't balance → empty string."""
|
|
assert context.unclosed_dollar_result == "", (
|
|
f"Expected empty string, got {context.unclosed_dollar_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _skip_quoted: lone dollar sign (line 204)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("SQL content with a lone dollar sign that is not a dollar-quote tag")
|
|
def step_content_lone_dollar(context):
|
|
"""Build content with a $ that does not form a $$...$$ tag.
|
|
|
|
A lone '$' at a position where _DOLLAR_RE.match fails exercises line 204
|
|
(return i + 1). We need the $ to appear in a position where it triggers
|
|
the dollar-check but does not match the pattern $tag$.
|
|
|
|
_DOLLAR_RE matches $tag$ patterns. A lone "$" followed by a non-word
|
|
char (like a space) won't match. The check in extract_body is:
|
|
ch == "$" and _DOLLAR_RE.match(content, i)
|
|
If _DOLLAR_RE.match fails, the "and" short-circuits and the "$" is NOT
|
|
sent to _skip_quoted. Line 204 is the fallback "return i+1" inside
|
|
_skip_quoted when ch == "$" but the dollar-regex doesn't match at
|
|
that position.
|
|
|
|
We call _skip_quoted directly with a "$" where the pattern fails.
|
|
"""
|
|
# We'll test _skip_quoted directly for line 204
|
|
context.lone_dollar_text = "$ not a tag"
|
|
context.lone_dollar_pos = 0
|
|
|
|
|
|
@when("I call extract_body on the lone-dollar content")
|
|
def step_call_skip_quoted_lone_dollar(context):
|
|
"""Call _skip_quoted directly to exercise line 204."""
|
|
context.lone_dollar_result = _skip_quoted(
|
|
context.lone_dollar_text, context.lone_dollar_pos
|
|
)
|
|
|
|
|
|
@then("the body should be extracted correctly ignoring the lone dollar")
|
|
def step_verify_lone_dollar(context):
|
|
"""_skip_quoted should return i+1 when $ doesn't form a dollar-quote."""
|
|
# For "$ not a tag" at position 0:
|
|
# ch == "$", _DOLLAR_RE.match("$ not a tag", 0) tries to match \$(\w*)\$
|
|
# at position 0 — "$ " does not have a second $, so no match → return 0+1=1
|
|
assert context.lone_dollar_result == 1, (
|
|
f"Expected 1, got {context.lone_dollar_result}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_body: invalid paren_start (line 215)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call extract_body with paren_start beyond content length")
|
|
def step_extract_body_beyond_length(context):
|
|
"""Call extract_body with paren_start >= len(content)."""
|
|
context.beyond_result = extract_body("(abc)", 999)
|
|
|
|
|
|
@then("extract_body should return an empty string for invalid start")
|
|
def step_verify_empty_beyond(context):
|
|
"""Line 215 returns "" for out-of-range paren_start."""
|
|
assert context.beyond_result == "", (
|
|
f"Expected empty string, got {context.beyond_result!r}"
|
|
)
|
|
|
|
|
|
@when("I call extract_body with paren_start pointing at a non-paren character")
|
|
def step_extract_body_non_paren(context):
|
|
"""Call extract_body where content[paren_start] != '('."""
|
|
context.non_paren_result = extract_body("abc(def)", 0)
|
|
|
|
|
|
@then("extract_body should return an empty string for non-paren start")
|
|
def step_verify_empty_non_paren(context):
|
|
"""Line 215 returns "" when content[paren_start] is not '('."""
|
|
assert context.non_paren_result == "", (
|
|
f"Expected empty string, got {context.non_paren_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_body: unbalanced parens (line 230)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("SQL content with an opening paren but no closing paren")
|
|
def step_content_unbalanced(context):
|
|
"""Content with an opening paren that is never closed."""
|
|
context.unbalanced_content = "(col1 TEXT, col2 INT"
|
|
context.unbalanced_start = 0
|
|
|
|
|
|
@when("I call extract_body on the unbalanced content")
|
|
def step_call_extract_body_unbalanced(context):
|
|
"""Call extract_body."""
|
|
context.unbalanced_result = extract_body(
|
|
context.unbalanced_content, context.unbalanced_start
|
|
)
|
|
|
|
|
|
@then("extract_body should return an empty string for missing close paren")
|
|
def step_verify_unbalanced_empty(context):
|
|
"""Line 230: loop exits without finding close paren → return ""."""
|
|
assert context.unbalanced_result == "", (
|
|
f"Expected empty string, got {context.unbalanced_result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_fk_triples: column count mismatch (lines 383-388)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a FOREIGN KEY clause with mismatched source and target column counts")
|
|
def step_fk_mismatch(context):
|
|
"""Build a FOREIGN KEY clause where source has 2 cols but target has 1.
|
|
|
|
FOREIGN KEY (col_a, col_b) REFERENCES other_table (col_x)
|
|
"""
|
|
fk_sql = "FOREIGN KEY (col_a, col_b) REFERENCES other_table (col_x)"
|
|
m = FOREIGN_KEY_RE.search(fk_sql)
|
|
assert m is not None, f"FOREIGN_KEY_RE did not match: {fk_sql!r}"
|
|
context.fk_mismatch_match = m
|
|
context.fk_resource_uri = "test-resource"
|
|
context.fk_table_name = "my_table"
|
|
context.fk_table_uri = table_uri("test-resource", "my_table")
|
|
|
|
|
|
@when("I extract FK triples from the mismatched clause")
|
|
def step_extract_fk_mismatch(context):
|
|
"""Call extract_fk_triples and capture log output."""
|
|
context.fk_log_records = []
|
|
|
|
class ListHandler(logging.Handler):
|
|
def __init__(self, records_list):
|
|
super().__init__()
|
|
self._records = records_list
|
|
|
|
def emit(self, record):
|
|
self._records.append(record)
|
|
|
|
_handler = ListHandler(context.fk_log_records)
|
|
# Attach to the module's logger
|
|
pg_logger = logging.getLogger("cleveragents.domain.models.acms._postgresql_helpers")
|
|
# Re-enable the logger in case Alembic's fileConfig() disabled it
|
|
pg_logger.disabled = False
|
|
pg_logger.addHandler(_handler)
|
|
pg_logger.setLevel(logging.DEBUG)
|
|
|
|
try:
|
|
context.fk_mismatch_triples = extract_fk_triples(
|
|
context.fk_mismatch_match,
|
|
context.fk_resource_uri,
|
|
context.fk_table_name,
|
|
context.fk_table_uri,
|
|
)
|
|
finally:
|
|
pg_logger.removeHandler(_handler)
|
|
|
|
|
|
@then("a warning should be logged about the column count mismatch")
|
|
def step_verify_fk_warning(context):
|
|
"""Lines 384-385, 387-388: warning about mismatched column counts."""
|
|
warnings = [r for r in context.fk_log_records if r.levelno == logging.WARNING]
|
|
assert len(warnings) >= 1, (
|
|
f"Expected a WARNING log, got {len(warnings)} warnings; "
|
|
f"all records: {[r.getMessage() for r in context.fk_log_records]}"
|
|
)
|
|
msg = warnings[0].getMessage()
|
|
assert "mismatch" in msg.lower(), f"Unexpected warning message: {msg}"
|
|
|
|
|
|
@then("triples should be generated only for the shorter column list")
|
|
def step_verify_fk_paired(context):
|
|
"""Only 1 foreignKeyTo triple (min of 2 source, 1 target)."""
|
|
fk_to_triples = [
|
|
t for t in context.fk_mismatch_triples if t.predicate == "uko-data:foreignKeyTo"
|
|
]
|
|
assert len(fk_to_triples) == 1, (
|
|
f"Expected 1 foreignKeyTo triple (min pairing), got {len(fk_to_triples)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_table_body: empty entry (line 434)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a table body string with empty entries between commas")
|
|
def step_body_empty_entries(context):
|
|
"""A body like ' , col1 INT, , col2 TEXT, ' — has empty entries."""
|
|
context.empty_entries_body = " , col1 INT, , col2 TEXT, "
|
|
context.empty_entries_resource = "test-resource"
|
|
context.empty_entries_table = "my_table"
|
|
context.empty_entries_table_uri = table_uri("test-resource", "my_table")
|
|
|
|
|
|
@when("I call parse_table_body on the body with empty entries")
|
|
def step_call_parse_table_body_empty(context):
|
|
"""Call parse_table_body."""
|
|
context.empty_triples = parse_table_body(
|
|
context.empty_entries_body,
|
|
context.empty_entries_resource,
|
|
context.empty_entries_table,
|
|
context.empty_entries_table_uri,
|
|
)
|
|
|
|
|
|
@then("parse_table_body should produce triples only for valid column definitions")
|
|
def step_verify_empty_entries_triples(context):
|
|
"""Only 2 real columns (col1, col2) should produce triples; empties are skipped."""
|
|
label_triples = [t for t in context.empty_triples if t.predicate == "rdfs:label"]
|
|
assert len(label_triples) == 2, (
|
|
f"Expected 2 column labels, got {len(label_triples)}: "
|
|
f"{[t.object_value for t in label_triples]}"
|
|
)
|
|
names = {t.object_value for t in label_triples}
|
|
assert names == {"col1", "col2"}, f"Unexpected column names: {names}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FK with quoted schema containing escaped double-quotes (line 50 via ident_pair)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a FOREIGN KEY clause referencing a quoted schema with escaped double-quotes")
|
|
def step_fk_quoted_schema(context):
|
|
"""Build: FOREIGN KEY (fk_col) REFERENCES "my""schema"."ref_table" (id)"""
|
|
fk_sql = 'FOREIGN KEY (fk_col) REFERENCES "my""schema"."ref_table" (id)'
|
|
m = FOREIGN_KEY_RE.search(fk_sql)
|
|
assert m is not None, f"FOREIGN_KEY_RE did not match: {fk_sql!r}"
|
|
context.fk_quoted_match = m
|
|
context.fk_quoted_resource = "test-resource"
|
|
context.fk_quoted_table = "source_table"
|
|
context.fk_quoted_table_uri = table_uri("test-resource", "source_table")
|
|
|
|
|
|
@when("I extract FK triples from the quoted-schema clause")
|
|
def step_extract_fk_quoted_schema(context):
|
|
"""Call extract_fk_triples with the quoted-schema FK match."""
|
|
context.fk_quoted_triples = extract_fk_triples(
|
|
context.fk_quoted_match,
|
|
context.fk_quoted_resource,
|
|
context.fk_quoted_table,
|
|
context.fk_quoted_table_uri,
|
|
)
|
|
|
|
|
|
@then("the triples should reference the correctly unescaped schema name")
|
|
def step_verify_fk_quoted_schema_uri(context):
|
|
"""The target column URI should contain the unescaped schema name."""
|
|
fk_to_triples = [
|
|
t for t in context.fk_quoted_triples if t.predicate == "uko-data:foreignKeyTo"
|
|
]
|
|
assert len(fk_to_triples) == 1
|
|
target_uri = fk_to_triples[0].object_uri
|
|
# The schema "my""schema" should be unescaped to my"schema,
|
|
# then safe_name converts " to _, so it becomes my_schema
|
|
assert "my_schema" in target_uri, f"Expected 'my_schema' in URI, got {target_uri!r}"
|