From 2837b9223128fa038ea3e83cecc935fbc8a9d406 Mon Sep 17 00:00:00 2001 From: Johnathan Brennan Date: Thu, 29 Jan 2026 19:32:09 -0500 Subject: [PATCH] Tossing old monitor-utility code and starting fresh. Addressed all comments. Moved CLI to correct location, updated tests for monitor and cli, used valid monitor items and verified terraform. Integration tests passing and 12/12. No more manual monitor creations within the tests. --- src/critic/cli.py | 57 ++++++++++++++- src/critic/monitor_utility.py | 80 ++++++++++++++++++++++ tests/critic_tests/conftest.py | 4 ++ tests/critic_tests/test_cli.py | 13 +++- tests/critic_tests/test_monitor_utility.py | 68 ++++++++++++++++++ 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/critic/monitor_utility.py create mode 100644 tests/critic_tests/test_monitor_utility.py diff --git a/src/critic/cli.py b/src/critic/cli.py index 19a5483..a281ffe 100644 --- a/src/critic/cli.py +++ b/src/critic/cli.py @@ -1,2 +1,55 @@ -def main(): - print('Hello from', __name__) +import os + +import click + +from critic.monitor_utility import create_monitors, delete_monitors +from critic.tables import UptimeMonitorTable + + +@click.group() +def cli(): + pass + + +def _default_monitor_table_name() -> str: + # Allow overriding the exact DynamoDB table name if needed. + # Otherwise use the generic Critic table name. + return os.environ.get('MONITOR_TABLE_NAME', UptimeMonitorTable.name()) + + +@cli.command('create-monitors') +@click.option('--project-id', required=True) +@click.option('--prefix', default='demo', show_default=True) +@click.option('--count', default=10, type=int, show_default=True) +@click.option('--table-name', default=None) +def create_monitors_cmd(project_id: str, prefix: str, count: int, table_name: str | None): + # Failsafe added to use default table name if not provided. + table_name = table_name or _default_monitor_table_name() + created = create_monitors( + table_name=table_name, + project_id=project_id, + prefix=prefix, + count=count, + ddb=None, + ) + click.echo(f'Created {created} monitors in {table_name}') + + +@cli.command('delete-monitors') +@click.option('--project-id', required=True) +@click.option('--prefix', required=True) +@click.option('--table-name', default=None) +def delete_monitors_cmd(project_id: str, prefix: str, table_name: str | None): + table_name = table_name or _default_monitor_table_name() + deleted = delete_monitors( + table_name=table_name, + project_id=project_id, + prefix=prefix, + ddb=None, + ) + click.echo(f'Deleted {deleted} monitors from {table_name}') + + +if __name__ == '__main__': + # Allows running CLI commands directly. + cli() diff --git a/src/critic/monitor_utility.py b/src/critic/monitor_utility.py new file mode 100644 index 0000000..ea11b5e --- /dev/null +++ b/src/critic/monitor_utility.py @@ -0,0 +1,80 @@ +import os + +import boto3 +from boto3.dynamodb.conditions import Key + +# from critic.libs.ddb import floats_to_decimals +from critic.models import UptimeMonitorModel +from critic.tables import UptimeMonitorTable + + +def _table(table_name: str, ddb: boto3.resources.base.ServiceResource | None = None): + if ddb is None: + ddb = boto3.resource( + 'dynamodb', + region_name=os.environ.get('AWS_DEFAULT_REGION', 'us-east-1'), + ) + return ddb.Table(table_name) + + +def _monitor_item(project_id: str, slug: str) -> dict: + # Build monitor data that passes UptimeMonitorModel validation. + inst = UptimeMonitorModel( + project_id=project_id, + slug=slug, + url='https://www.google.com', + frequency_mins=5, + next_due_at='2025-11-10T20:35:00Z', + timeout_secs=30, + assertions={'status_code': 200}, + failures_before_alerting=2, + alert_slack_channels=[], + alert_emails=[], + realert_interval_mins=60, + ) + + # Resource API expects plain python types, NOT DynamoDB AttributeValue format. + plain = inst.model_dump(mode='json', exclude_none=True) + return plain + + +def create_monitors( + table_name: str, + project_id: str, + prefix: str, + count: int, + ddb: boto3.resources.base.ServiceResource | None = None, +) -> int: + for i in range(1, count + 1): + slug = f'{prefix}-{i:04d}' + item = _monitor_item(project_id=project_id, slug=slug) + + # Use table extraction. Fall back to boto3 only if override is necessary + if table_name == UptimeMonitorTable.name(): + UptimeMonitorTable.put(item) + else: + _table(table_name, ddb).put_item(Item=item) + + return count + + +def delete_monitors( + table_name: str, + project_id: str, + prefix: str, + ddb: boto3.resources.base.ServiceResource | None = None, +) -> int: + t = _table(table_name, ddb) + + resp = t.query( + KeyConditionExpression=Key('project_id').eq(project_id), + ProjectionExpression='project_id, slug', + ) + items = resp.get('Items', []) + to_delete = [item for item in items if item['slug'].startswith(prefix)] + + with t.batch_writer() as bw: + for item in to_delete: + bw.delete_item(Key={'project_id': item['project_id'], 'slug': item['slug']}) + + return len(to_delete) diff --git a/tests/critic_tests/conftest.py b/tests/critic_tests/conftest.py index 990142c..ca04f32 100644 --- a/tests/critic_tests/conftest.py +++ b/tests/critic_tests/conftest.py @@ -40,7 +40,11 @@ def moto_for_unit_tests(request): clear_tables() else: # Unit test → activate moto + # Set default env vars for moto if not already set + os.environ.setdefault('AWS_DEFAULT_REGION', 'us-east-1') + os.environ.setdefault('CRITIC_NAMESPACE', 'critic-test-') with mock_aws(): + ddb_module._ddb_client = None # Reset cached client before tables are created create_tables() yield # The DDB module is designed to cache the client. When we're testing unit tests and diff --git a/tests/critic_tests/test_cli.py b/tests/critic_tests/test_cli.py index 680ec0a..879e23c 100644 --- a/tests/critic_tests/test_cli.py +++ b/tests/critic_tests/test_cli.py @@ -1,5 +1,12 @@ -from critic.cli import main +from click.testing import CliRunner +from critic.cli import cli -def test_main(): - main() + +# Changed the tests for cli to actually verify that the cli is functioning as intended. +def test_cli_help(): + runner = CliRunner() + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'create-monitors' in result.output + assert 'delete-monitors' in result.output diff --git a/tests/critic_tests/test_monitor_utility.py b/tests/critic_tests/test_monitor_utility.py new file mode 100644 index 0000000..2135ec7 --- /dev/null +++ b/tests/critic_tests/test_monitor_utility.py @@ -0,0 +1,68 @@ +from critic.libs.ddb import deserialize, get_client, serialize +from critic.monitor_utility import create_monitors, delete_monitors +from critic.tables import UptimeMonitorTable + + +def _query_project_items(table_name: str, project_id: str) -> list[dict]: + resp = get_client().query( + TableName=table_name, + KeyConditionExpression='project_id = :project_id', + ExpressionAttributeValues=serialize( + { + ':project_id': project_id, + } + ), + ) + return [deserialize(item) for item in resp.get('Items', [])] + + +def test_create_and_delete_monitors(): + # Test creating and deleting monitors in DynamoDB. + table_name = UptimeMonitorTable.name() + + project_id = '00000000-0000-0000-0000-000000000001' + prefix = 'stress' + + created = create_monitors( + table_name=table_name, + project_id=project_id, + prefix=prefix, + count=10, + ddb=None, + ) + assert created == 10 + + items = _query_project_items(table_name, project_id) + assert len(items) == 10 + + deleted = delete_monitors( + table_name=table_name, + project_id=project_id, + prefix=prefix, + ddb=None, + ) + assert deleted == 10 + + items_after = _query_project_items(table_name, project_id) + assert items_after == [] + + +def test_delete_only_matches_prefix(): + # Test that delete_monitors only deletes items matching the given prefix. + table_name = UptimeMonitorTable.name() + + project_id = '00000000-0000-0000-0000-000000000001' + prefix = 'stress' + + create_monitors(table_name, project_id, prefix, 5, ddb=None) + create_monitors(table_name, project_id, 'other', 3, ddb=None) + + items_before = _query_project_items(table_name, project_id) + assert len(items_before) == 8 + + deleted = delete_monitors(table_name, project_id, prefix, ddb=None) + assert deleted == 5 + + items_after = _query_project_items(table_name, project_id) + remaining_slugs = {item['slug'] for item in items_after} + assert remaining_slugs == {f'other-{i:04d}' for i in range(1, 4)}