diff --git a/pyproject.toml b/pyproject.toml index 9bd3020..d1d6e61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.6.0" description = "A command-line tool for browsing Reddit without requiring an API key" readme = "README.md" requires-python = ">=3.14" -dependencies = ["typer", "httpx", "pydantic"] +dependencies = ["typer", "httpx", "pydantic", "openpyxl>=3.1.0"] [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "respx", "httpx"] diff --git a/reddit_cli/commands/browse.py b/reddit_cli/commands/browse.py index fe0f921..f99600d 100644 --- a/reddit_cli/commands/browse.py +++ b/reddit_cli/commands/browse.py @@ -8,12 +8,13 @@ post_csv_header, ) from reddit_cli.reddit import RedditClient, PostsClient +from reddit_cli.xlsx_export import posts_to_xlsx # Valid values for CLI validation VALID_SORT_VALUES = ["hot", "new", "top", "rising", "controversial", "gilded"] VALID_PERIOD_VALUES = ["day", "week", "month", "year", "all"] -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] def _validate_sort_period(sort: str, period: str | None, limit: int) -> None: @@ -47,12 +48,22 @@ def _write_output( Args: posts: List of Post objects - format_type: Output format (display, sql, csv) + format_type: Output format (display, sql, csv, xlsx) output_file: File path or None for stdout """ if format_type == "display": return + if format_type == "xlsx": + if not output_file: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = posts_to_xlsx(posts) + with open(output_file, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported {len(posts)} posts to {output_file}") + return + lines: list[str] = [] if format_type == "csv": lines.append(post_csv_header()) @@ -151,7 +162,7 @@ def browse( period: Time period for top/controversial (day, week, month, year, all) after: Pagination cursor (get posts after this ID) before: Pagination cursor (get posts before this ID) - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: diff --git a/reddit_cli/commands/comments.py b/reddit_cli/commands/comments.py index 5cb8eb9..00e5f47 100644 --- a/reddit_cli/commands/comments.py +++ b/reddit_cli/commands/comments.py @@ -9,9 +9,10 @@ comment_csv_header, ) from reddit_cli.reddit import RedditClient, CommentsClient, Comment +from reddit_cli.xlsx_export import comments_to_xlsx -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] def _flatten_comments(comments: list[Comment]) -> list[Comment]: @@ -40,7 +41,7 @@ def _write_comments_output( Args: comments: List of Comment objects - format_type: Output format (display, sql, csv) + format_type: Output format (display, sql, csv, xlsx) output_file: File path or None for stdout """ flat_comments = _flatten_comments(comments) @@ -48,6 +49,16 @@ def _write_comments_output( if format_type == "display": return + if format_type == "xlsx": + if not output_file: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = comments_to_xlsx(comments) + with open(output_file, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported {len(flat_comments)} comments to {output_file}") + return + lines: list[str] = [] if format_type == "csv": lines.append(comment_csv_header()) @@ -107,7 +118,7 @@ def comments( post_id: Post ID (with or without t3_ prefix) sort: Sort type (confidence, top, new, old, controversial, qa) depth: Maximum comment depth - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: @@ -138,7 +149,7 @@ def comment( post_id: Post ID (with or without t3_ prefix) comment_id: Comment ID (with or without t1_ prefix) replies: Include replies - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: diff --git a/reddit_cli/commands/navigation.py b/reddit_cli/commands/navigation.py index b68e88f..e74404e 100644 --- a/reddit_cli/commands/navigation.py +++ b/reddit_cli/commands/navigation.py @@ -8,12 +8,13 @@ post_csv_header, ) from reddit_cli.reddit import RedditClient, PostsClient +from reddit_cli.xlsx_export import posts_to_xlsx # Valid values for CLI validation VALID_SORT_VALUES = ["hot", "new", "top", "rising", "controversial", "gilded"] VALID_PERIOD_VALUES = ["day", "week", "month", "year", "all"] -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] def _validate_sort_period(sort: str, period: str | None, limit: int) -> None: @@ -47,12 +48,22 @@ def _write_posts_output( Args: posts: List of Post objects - format_type: Output format (display, sql, csv) + format_type: Output format (display, sql, csv, xlsx) output_file: File path or None for stdout """ if format_type == "display": return + if format_type == "xlsx": + if not output_file: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = posts_to_xlsx(posts) + with open(output_file, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported {len(posts)} posts to {output_file}") + return + lines: list[str] = [] if format_type == "csv": lines.append(post_csv_header()) @@ -126,7 +137,7 @@ def frontpage( period: Time period for top/controversial (day, week, month, year, all) after: Pagination cursor before: Pagination cursor - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: @@ -163,7 +174,7 @@ def home( period: Time period for top/controversial (day, week, month, year, all) after: Pagination cursor before: Pagination cursor - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: @@ -198,7 +209,7 @@ def best( period: Time period (day, week, month, year, all) after: Pagination cursor before: Pagination cursor - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: diff --git a/reddit_cli/commands/post.py b/reddit_cli/commands/post.py index 60c08cd..71efc26 100644 --- a/reddit_cli/commands/post.py +++ b/reddit_cli/commands/post.py @@ -5,9 +5,10 @@ from reddit_cli.errors import handle_api_error, handle_validation_error from reddit_cli.export import post_to_sql_insert, post_to_csv_row, post_csv_header from reddit_cli.reddit import RedditClient, PostsClient +from reddit_cli.xlsx_export import posts_to_xlsx -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] async def _post_async(post_id: str): @@ -35,7 +36,7 @@ def post( post_id: Post ID (with or without t3_ prefix) view: Show post details info: Show detailed post info - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: @@ -55,6 +56,14 @@ def post( typer.echo() if post_obj.selftext: typer.echo(post_obj.selftext.encode(sys.stdout.encoding, errors="replace").decode(sys.stdout.encoding)) + elif format == "xlsx": + if not output: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = posts_to_xlsx([post_obj]) + with open(output, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported 1 post to {output}") else: lines = [] if format == "csv": diff --git a/reddit_cli/commands/search.py b/reddit_cli/commands/search.py index 1696bd5..eb6d62d 100644 --- a/reddit_cli/commands/search.py +++ b/reddit_cli/commands/search.py @@ -8,12 +8,13 @@ post_csv_header, ) from reddit_cli.reddit import RedditClient, PostsClient +from reddit_cli.xlsx_export import posts_to_xlsx # Valid values for CLI validation VALID_SEARCH_SORT_VALUES = ["relevance", "hot", "top", "new", "comments"] VALID_PERIOD_VALUES = ["hour", "day", "week", "month", "year", "all"] -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] def _validate_search_params(sort: str, period: str | None, limit: int) -> None: @@ -47,12 +48,22 @@ def _write_posts_output( Args: posts: List of Post objects - format_type: Output format (display, sql, csv) + format_type: Output format (display, sql, csv, xlsx) output_file: File path or None for stdout """ if format_type == "display": return + if format_type == "xlsx": + if not output_file: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = posts_to_xlsx(posts) + with open(output_file, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported {len(posts)} posts to {output_file}") + return + lines: list[str] = [] if format_type == "csv": lines.append(post_csv_header()) @@ -123,7 +134,7 @@ def search( sort: Sort type (relevance, hot, top, new, comments) limit: Number of results period: Time period (day, week, month, year, all) - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: diff --git a/reddit_cli/commands/subreddit.py b/reddit_cli/commands/subreddit.py index ee155da..1aca07c 100644 --- a/reddit_cli/commands/subreddit.py +++ b/reddit_cli/commands/subreddit.py @@ -9,11 +9,12 @@ subreddit_csv_header, ) from reddit_cli.reddit import RedditClient, SubredditsClient +from reddit_cli.xlsx_export import subreddits_to_xlsx # Valid values for CLI validation VALID_SUBREDDIT_SORT_VALUES = ["gilded", "subscribers", "active"] -VALID_FORMAT_VALUES = ["display", "sql", "csv"] +VALID_FORMAT_VALUES = ["display", "sql", "csv", "xlsx"] def _validate_list_params(sort: str, limit: int) -> None: @@ -35,6 +36,16 @@ def _write_subreddits_output( if format_type == "display": return + if format_type == "xlsx": + if not output_file: + typer.echo("Error: --output is required for xlsx format", err=True) + raise typer.Exit(code=2) + xlsx_data = subreddits_to_xlsx(subreddits) + with open(output_file, "wb") as f: + f.write(xlsx_data) + typer.echo(f"Exported {len(subreddits)} subreddits to {output_file}") + return + lines: list[str] = [] if format_type == "csv": lines.append(subreddit_csv_header()) @@ -85,7 +96,7 @@ def subreddit( Args: name: Subreddit name (with or without r/ prefix) rules: Show subreddit rules - format: Output format (display, sql, csv) + format: Output format (display, sql, csv, xlsx) output: Output file path """ try: @@ -137,7 +148,7 @@ async def _list_subreddits_async( def subreddits_popular( sort: str = typer.Option("subscribers", "--sort", help="Sort type (subscribers, active, gilded)"), limit: int = typer.Option(25, "--limit", help="Number of results"), - format: str = typer.Option("display", "--format", help="Output format (display, sql, csv)"), + format: str = typer.Option("display", "--format", help="Output format (display, sql, csv, xlsx)"), output: str | None = typer.Option(None, "--output", help="Output file path"), ) -> None: """List popular subreddits.""" @@ -191,7 +202,7 @@ async def _search_async( def subreddits_search( query: str = typer.Argument(..., help="Search query"), limit: int = typer.Option(25, "--limit", help="Number of results"), - format: str = typer.Option("display", "--format", help="Output format (display, sql, csv)"), + format: str = typer.Option("display", "--format", help="Output format (display, sql, csv, xlsx)"), output: str | None = typer.Option(None, "--output", help="Output file path"), ) -> None: """Search subreddits by keyword.""" @@ -227,7 +238,7 @@ async def _new_async( @subreddits_app.command(name="new") def subreddits_new( limit: int = typer.Option(25, "--limit", help="Number of results"), - format: str = typer.Option("display", "--format", help="Output format (display, sql, csv)"), + format: str = typer.Option("display", "--format", help="Output format (display, sql, csv, xlsx)"), output: str | None = typer.Option(None, "--output", help="Output file path"), ) -> None: """List newly created subreddits.""" @@ -263,7 +274,7 @@ async def _gold_async( @subreddits_app.command(name="gold") def subreddits_gold( limit: int = typer.Option(25, "--limit", help="Number of results"), - format: str = typer.Option("display", "--format", help="Output format (display, sql, csv)"), + format: str = typer.Option("display", "--format", help="Output format (display, sql, csv, xlsx)"), output: str | None = typer.Option(None, "--output", help="Output file path"), ) -> None: """List Reddit Gold subreddits.""" @@ -299,7 +310,7 @@ async def _default_async( @subreddits_app.command(name="default") def subreddits_default( limit: int = typer.Option(25, "--limit", help="Number of results"), - format: str = typer.Option("display", "--format", help="Output format (display, sql, csv)"), + format: str = typer.Option("display", "--format", help="Output format (display, sql, csv, xlsx)"), output: str | None = typer.Option(None, "--output", help="Output file path"), ) -> None: """List default subreddits.""" diff --git a/reddit_cli/errors.py b/reddit_cli/errors.py index ff80e13..4d419b5 100644 --- a/reddit_cli/errors.py +++ b/reddit_cli/errors.py @@ -19,6 +19,9 @@ def handle_api_error(e: Exception) -> None: 1: General error (network issues, API errors) 2: Usage error (invalid arguments) """ + # Re-raise typer.Exit exceptions without modification + if isinstance(e, typer.Exit): + raise e if isinstance(e, httpx.TimeoutException): typer.echo("Error: Connection timed out. Please check your internet connection and try again.", err=True) raise typer.Exit(code=EXIT_GENERAL_ERROR) diff --git a/reddit_cli/xlsx_export.py b/reddit_cli/xlsx_export.py new file mode 100644 index 0000000..ba9c5ea --- /dev/null +++ b/reddit_cli/xlsx_export.py @@ -0,0 +1,196 @@ +"""XLSX export utilities for Reddit CLI.""" + +from io import BytesIO + +from reddit_cli.reddit.models import Comment, Post, Subreddit + + +def _check_openpyxl() -> None: + """Check if openpyxl is installed. + + Raises: + ImportError: If openpyxl is not installed + """ + try: + import openpyxl # noqa: F401 + except ImportError: + raise ImportError( + "openpyxl is required for XLSX export. " + "Please install it with: pip install openpyxl" + ) + + +def posts_to_xlsx(posts: list[Post], sheet_name: str = "Posts") -> bytes: + """Convert a list of posts to XLSX format. + + Args: + posts: List of Post objects + sheet_name: Name of the worksheet + + Returns: + XLSX file content as bytes + + Raises: + ImportError: If openpyxl is not installed + """ + _check_openpyxl() + + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = sheet_name + + # Headers + headers = ["id", "title", "author", "subreddit", "score", "num_comments", "url", "permalink", "created_utc", "selftext"] + ws.append(headers) + + # Data rows + for post in posts: + ws.append([ + post.id, + post.title, + post.author, + post.subreddit, + post.score, + post.num_comments, + post.url, + post.permalink, + post.created_utc, + post.selftext, + ]) + + # Auto-adjust column widths + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + buffer = BytesIO() + wb.save(buffer) + buffer.seek(0) + return buffer.getvalue() + + +def comments_to_xlsx(comments: list[Comment], sheet_name: str = "Comments") -> bytes: + """Convert a list of comments to XLSX format. + + Args: + comments: List of Comment objects + sheet_name: Name of the worksheet + + Returns: + XLSX file content as bytes + + Raises: + ImportError: If openpyxl is not installed + """ + _check_openpyxl() + + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = sheet_name + + # Headers + headers = ["id", "author", "body", "score", "created_utc", "parent_id", "link_id", "depth"] + ws.append(headers) + + # Flatten comments and add rows + def flatten_and_add(comments_list: list[Comment]) -> None: + for comment in comments_list: + ws.append([ + comment.id, + comment.author, + comment.body, + comment.score, + comment.created_utc, + comment.parent_id, + comment.link_id, + comment.depth, + ]) + if comment.replies: + flatten_and_add(comment.replies) + + flatten_and_add(comments) + + # Auto-adjust column widths + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + buffer = BytesIO() + wb.save(buffer) + buffer.seek(0) + return buffer.getvalue() + + +def subreddits_to_xlsx(subreddits: list[Subreddit], sheet_name: str = "Subreddits") -> bytes: + """Convert a list of subreddits to XLSX format. + + Args: + subreddits: List of Subreddit objects + sheet_name: Name of the worksheet + + Returns: + XLSX file content as bytes + + Raises: + ImportError: If openpyxl is not installed + """ + _check_openpyxl() + + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = sheet_name + + # Headers + headers = ["id", "display_name", "title", "description", "subscribers", "active_users"] + ws.append(headers) + + # Data rows + for subreddit in subreddits: + ws.append([ + subreddit.id, + subreddit.display_name, + subreddit.title, + subreddit.description, + subreddit.subscribers, + subreddit.active_users, + ]) + + # Auto-adjust column widths + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + buffer = BytesIO() + wb.save(buffer) + buffer.seek(0) + return buffer.getvalue() diff --git a/tests/__pycache__/test_cli_browse.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_browse.cpython-314-pytest-9.0.2.pyc index c6afb86..793a232 100644 Binary files a/tests/__pycache__/test_cli_browse.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_browse.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_comments.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_comments.cpython-314-pytest-9.0.2.pyc index abb72ca..2b07c0c 100644 Binary files a/tests/__pycache__/test_cli_comments.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_comments.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_navigation.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_navigation.cpython-314-pytest-9.0.2.pyc index b99373d..4b34ce0 100644 Binary files a/tests/__pycache__/test_cli_navigation.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_navigation.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_post.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_post.cpython-314-pytest-9.0.2.pyc index 820c210..a6b340d 100644 Binary files a/tests/__pycache__/test_cli_post.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_post.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_search.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_search.cpython-314-pytest-9.0.2.pyc index c6126f5..c33c132 100644 Binary files a/tests/__pycache__/test_cli_search.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_search.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_subreddit.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_subreddit.cpython-314-pytest-9.0.2.pyc index 9070e70..d208eda 100644 Binary files a/tests/__pycache__/test_cli_subreddit.cpython-314-pytest-9.0.2.pyc and b/tests/__pycache__/test_cli_subreddit.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_errors.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_errors.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..4f6ef76 Binary files /dev/null and b/tests/__pycache__/test_errors.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_export.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_export.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..5b11531 Binary files /dev/null and b/tests/__pycache__/test_export.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_posts.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_posts.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..e780623 Binary files /dev/null and b/tests/__pycache__/test_posts.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_xlsx_export.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_xlsx_export.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..8f09f9d Binary files /dev/null and b/tests/__pycache__/test_xlsx_export.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_xlsx_export.cpython-314.pyc b/tests/__pycache__/test_xlsx_export.cpython-314.pyc new file mode 100644 index 0000000..43e5565 Binary files /dev/null and b/tests/__pycache__/test_xlsx_export.cpython-314.pyc differ diff --git a/tests/test_cli_browse.py b/tests/test_cli_browse.py index fac1cea..28c0cba 100644 --- a/tests/test_cli_browse.py +++ b/tests/test_cli_browse.py @@ -1,4 +1,5 @@ """Tests for browse commands: browse, --sticky, --random, --search.""" +import os import pytest import httpx from typer.testing import CliRunner @@ -33,7 +34,6 @@ def sample_browse_response() -> dict: } } - class TestBrowse: """Test suite for browse command.""" @@ -93,8 +93,6 @@ def test_browse_missing_subreddit(self, runner: CliRunner): """browse should fail without subreddit argument.""" result = runner.invoke(app, ["browse"]) assert result.exit_code != 0 - - class TestBrowseSearch: """Test suite for browse --search command.""" @@ -122,8 +120,6 @@ def test_browse_search_missing_query(self, runner: CliRunner): """browse python --search should require query.""" result = runner.invoke(app, ["browse", "python", "--search"]) assert result.exit_code != 0 - - class TestBrowsePagination: """Test suite for browse pagination (--after/--before).""" @@ -165,16 +161,12 @@ def test_browse_pagination_output_shows_cursors( self, runner: CliRunner, mock_reddit_base, sample_browse_response ): """browse should display pagination cursors when available.""" - # Response has after="t3_after1" in fixture mock_reddit_base.get("/r/python/hot.json").mock( return_value=httpx.Response(200, json=sample_browse_response) ) result = runner.invoke(app, ["browse", "python"]) assert result.exit_code == 0 - # The sample_browse_response has after="t3_after1" assert "After:" in result.output - - class TestBrowseErrorHandling: """Test suite for browse error handling.""" @@ -188,3 +180,347 @@ def test_browse_search_no_results( result = runner.invoke(app, ["browse", "python", "--search", "nonexistent"]) assert result.exit_code == 0 assert "No posts found" in result.output +class TestBrowseValidation: + """Test suite for browse validation edge cases.""" + + def test_browse_invalid_sort_value( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject invalid --sort values.""" + result = runner.invoke(app, ["browse", "python", "--sort", "invalid_sort"]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + + def test_browse_invalid_period_value( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject invalid --period values.""" + mock_reddit_base.get("/r/python/top.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--sort", "top", "--period", "invalid_period"]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + + def test_browse_limit_zero( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject --limit 0.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--limit", "0"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_browse_limit_negative( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject negative --limit.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--limit", "-1"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_browse_limit_over_100( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject --limit over 100.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--limit", "101"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_browse_limit_100_boundary( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should accept --limit 100 (boundary).""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--limit", "100"]) + assert result.exit_code == 0 + + def test_browse_limit_1_boundary( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should accept --limit 1 (boundary).""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--limit", "1"]) + assert result.exit_code == 0 + + def test_browse_invalid_format_value( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should reject invalid --format values.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--format", "invalid_format"]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + +class TestBrowseFormats: + """Test suite for browse with different format options.""" + + def test_browse_format_display( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse with --format display should work (default).""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--format", "display"]) + assert result.exit_code == 0 + assert "Python Tip of the Day" in result.output + + def test_browse_format_sql( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse with --format sql should output SQL INSERT statements.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + + def test_browse_format_csv( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse with --format csv should output CSV data.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in result.output + + def test_browse_format_xlsx_without_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse with --format xlsx without --output should error.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "--output is required for xlsx format" in result.output + + def test_browse_format_xlsx_with_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response, tmp_path + ): + """browse with --format xlsx and --output should create file.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + output_file = tmp_path / "posts.xlsx" + result = runner.invoke( + app, ["browse", "python", "--format", "xlsx", "--output", str(output_file)] + ) + assert result.exit_code == 0 + assert f"Exported 1 posts to {output_file}" in result.output + assert output_file.exists() + + def test_browse_format_sql_with_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response, tmp_path + ): + """browse with --format sql and --output should write to file.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + output_file = tmp_path / "posts.sql" + result = runner.invoke( + app, ["browse", "python", "--format", "sql", "--output", str(output_file)] + ) + assert result.exit_code == 0 + assert f"Exported 1 posts to {output_file}" in result.output + assert output_file.exists() + + def test_browse_format_csv_with_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response, tmp_path + ): + """browse with --format csv and --output should write to file.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + output_file = tmp_path / "posts.csv" + result = runner.invoke( + app, ["browse", "python", "--format", "csv", "--output", str(output_file)] + ) + assert result.exit_code == 0 + assert f"Exported 1 posts to {output_file}" in result.output + assert output_file.exists() +class TestBrowseSearchFormats: + """Test suite for browse --search with different format options.""" + + def test_browse_search_format_display( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse --search with --format display should show search results.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--search", "django", "--format", "display"]) + assert result.exit_code == 0 + assert "Search results for 'django' in r/python:" in result.output + + def test_browse_search_format_sql( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse --search with --format sql should output SQL INSERT statements.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--search", "django", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + + def test_browse_search_format_csv( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse --search with --format csv should output CSV data.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--search", "django", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in result.output + + def test_browse_search_format_xlsx_without_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse --search with --format xlsx without --output should error.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--search", "django", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "--output is required for xlsx format" in result.output + + def test_browse_search_format_xlsx_with_output( + self, runner: CliRunner, mock_reddit_base, sample_browse_response, tmp_path + ): + """browse --search with --format xlsx and --output should create file.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + output_file = tmp_path / "search_results.xlsx" + result = runner.invoke( + app, ["browse", "python", "--search", "django", "--format", "xlsx", "--output", str(output_file)] + ) + assert result.exit_code == 0 + assert f"Exported 1 posts to {output_file}" in result.output + assert output_file.exists() +class TestBrowseApiErrors: + """Test suite for browse API error handling.""" + + def test_browse_api_404_error( + self, runner: CliRunner, mock_reddit_base, error_response_404 + ): + """browse should handle 404 API errors gracefully.""" + mock_reddit_base.get("/r/nonexistent subreddit 123/hot.json").mock( + return_value=error_response_404 + ) + result = runner.invoke(app, ["browse", "nonexistent subreddit 123"]) + assert result.exit_code == 1 + assert "Error:" in result.output + + def test_browse_api_500_error( + self, runner: CliRunner, mock_reddit_base, error_response_500 + ): + """browse should handle 500 API errors gracefully.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=error_response_500 + ) + result = runner.invoke(app, ["browse", "python"]) + assert result.exit_code == 1 + assert "Error:" in result.output + + def test_browse_api_429_error( + self, runner: CliRunner, mock_reddit_base, error_response_429 + ): + """browse should handle 429 (rate limit) API errors gracefully.""" + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=error_response_429 + ) + result = runner.invoke(app, ["browse", "python"]) + assert result.exit_code == 1 + assert "Error:" in result.output + + def test_browse_api_timeout_error( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should handle timeout errors gracefully.""" + mock_reddit_base.get("/r/python/hot.json").mock( + side_effect=httpx.TimeoutException("Connection timed out") + ) + result = runner.invoke(app, ["browse", "python"]) + assert result.exit_code == 1 + assert "Error:" in result.output + + def test_browse_search_api_error( + self, runner: CliRunner, mock_reddit_base, error_response_404 + ): + """browse --search should handle API errors gracefully.""" + mock_reddit_base.get("/r/python/search.json").mock( + return_value=error_response_404 + ) + result = runner.invoke(app, ["browse", "python", "--search", "django"]) + assert result.exit_code == 1 + assert "Error:" in result.output +class TestBrowseGildedSort: + """Test suite for browse with gilded sort option.""" + + def test_browse_gilded_sort( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should accept --sort gilded.""" + mock_reddit_base.get("/r/python/gilded.json").mock( + return_value=httpx.Response(200, json=sample_browse_response) + ) + result = runner.invoke(app, ["browse", "python", "--sort", "gilded"]) + assert result.exit_code == 0 + +class TestBrowseBeforeOnly: + """Test suite for browse with only before cursor (no after).""" + + def test_browse_before_cursor_no_after( + self, runner: CliRunner, mock_reddit_base, sample_browse_response + ): + """browse should display Before cursor when only before is present.""" + response_with_before = { + "data": { + "children": [ + { + "kind": "t3", + "data": { + "id": "post1", + "title": "Older Post", + "score": 500, + "num_comments": 100, + "author": "pythonista", + "subreddit": "python", + "url": "https://example.com/python-tip", + "permalink": "/r/python/comments/post1/python_tip/", + "selftext": "Use enumerate() instead of range()", + "created_utc": 1704067200, + }, + } + ], + "after": None, + "before": "t3_before1", + } + } + mock_reddit_base.get("/r/python/hot.json").mock( + return_value=httpx.Response(200, json=response_with_before) + ) + result = runner.invoke(app, ["browse", "python"]) + assert result.exit_code == 0 + assert "Before:" in result.output \ No newline at end of file diff --git a/tests/test_cli_comments.py b/tests/test_cli_comments.py index 31a7c3a..951f0ed 100644 --- a/tests/test_cli_comments.py +++ b/tests/test_cli_comments.py @@ -1,4 +1,6 @@ """Tests for comments commands: comments, comment.""" +import os +import tempfile import pytest import httpx from typer.testing import CliRunner @@ -169,6 +171,147 @@ def test_comments_missing_post_id(self, runner: CliRunner): result = runner.invoke(app, ["comments"]) assert result.exit_code != 0 + def test_comments_invalid_format( + self, runner: CliRunner + ): + """comments should exit with code 2 for invalid format.""" + # No mock needed - validation happens before API call + result = runner.invoke(app, ["comments", "abc123", "--format", "invalid"]) + # Note: exit code is 1 (not 2) due to typer.Exit being caught by except Exception + assert result.exit_code == 2 + assert "Invalid value" in result.output + + def test_comments_sql_format( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments should export in SQL format.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + result = runner.invoke(app, ["comments", "abc123", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO" in result.output + + def test_comments_csv_format( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments should export in CSV format.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + result = runner.invoke(app, ["comments", "abc123", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,author,body" in result.output + + def test_comments_sql_format_with_output( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments should export SQL to file.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".sql") as f: + output_path = f.name + try: + result = runner.invoke( + app, ["comments", "abc123", "--format", "sql", "--output", output_path] + ) + assert result.exit_code == 0 + assert "Exported" in result.output + with open(output_path, "r") as f: + content = f.read() + assert "INSERT INTO" in content + finally: + os.unlink(output_path) + + def test_comments_csv_format_with_output( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments should export CSV to file.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".csv") as f: + output_path = f.name + try: + result = runner.invoke( + app, ["comments", "abc123", "--format", "csv", "--output", output_path] + ) + assert result.exit_code == 0 + assert "Exported" in result.output + with open(output_path, "r") as f: + content = f.read() + assert "id,author,body" in content + finally: + os.unlink(output_path) + + def test_comments_xlsx_format_requires_output( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments with xlsx format without output should fail.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + result = runner.invoke(app, ["comments", "abc123", "--format", "xlsx"]) + # Note: exit code is 1 (not 2) due to typer.Exit being caught by except Exception + assert result.exit_code == 2 + assert "--output" in result.output + + def test_comments_xlsx_format_with_output( + self, runner: CliRunner, mock_reddit_base, sample_comments_response + ): + """comments should export XLSX to file.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response( + 200, json=[{"data": {"children": []}}, sample_comments_response] + ) + ) + with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".xlsx") as f: + output_path = f.name + try: + result = runner.invoke( + app, ["comments", "abc123", "--format", "xlsx", "--output", output_path] + ) + assert result.exit_code == 0 + assert "Exported" in result.output + with open(output_path, "rb") as f: + content = f.read() + assert len(content) > 0 + finally: + os.unlink(output_path) + + def test_comments_api_error_handling( + self, runner: CliRunner, mock_reddit_base + ): + """comments should handle API errors gracefully.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + return_value=httpx.Response(500, json={"error": 500}) + ) + result = runner.invoke(app, ["comments", "abc123"]) + assert result.exit_code == 1 + assert "Error" in result.output + + def test_comments_timeout_error( + self, runner: CliRunner, mock_reddit_base + ): + """comments should handle timeout errors.""" + mock_reddit_base.get("/comments/abc123.json?sort=confidence").mock( + side_effect=httpx.TimeoutException("Connection timed out") + ) + result = runner.invoke(app, ["comments", "abc123"]) + assert result.exit_code == 1 + assert "timed out" in result.output.lower() + class TestComment: """Test suite for comment (single comment) command.""" diff --git a/tests/test_cli_navigation.py b/tests/test_cli_navigation.py index 4f83491..0dacfbf 100644 --- a/tests/test_cli_navigation.py +++ b/tests/test_cli_navigation.py @@ -1,4 +1,5 @@ """Tests for navigation commands: frontpage, home, best.""" +import pytest import httpx from typer.testing import CliRunner @@ -123,3 +124,288 @@ def test_best_with_after_cursor( ) result = runner.invoke(app, ["best", "--after", "t3_previous"]) assert result.exit_code == 0 +class TestNavigationDisplayFormat: + """Test display format with cursors.""" + + def test_frontpage_display_shows_after_cursor(self, runner, mock_reddit_base, sample_navigation_response): + sample_navigation_response["data"]["after"] = "t3_after123" + sample_navigation_response["data"]["before"] = None + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 0 + assert "After: t3_after123" in result.output + +class TestNavigationDisplayFormat: + """Test display format with cursors.""" + + def test_frontpage_display_shows_after_cursor(self, runner, mock_reddit_base, sample_navigation_response): + sample_navigation_response["data"]["after"] = "t3_after123" + sample_navigation_response["data"]["before"] = None + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 0 + assert "After: t3_after123" in result.output + + def test_frontpage_display_shows_before_cursor(self, runner, mock_reddit_base, sample_navigation_response): + sample_navigation_response["data"]["after"] = None + sample_navigation_response["data"]["before"] = "t3_before456" + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 0 + assert "Before: t3_before456" in result.output + + def test_frontpage_display_shows_both_cursors(self, runner, mock_reddit_base, sample_navigation_response): + sample_navigation_response["data"]["after"] = "t3_after123" + sample_navigation_response["data"]["before"] = "t3_before456" + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 0 + assert "After: t3_after123" in result.output + assert "Before: t3_before456" in result.output + +class TestNavigationFormatOptions: + """Test format options for navigation commands.""" + + def test_frontpage_csv_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in result.output + assert "abc123" in result.output + + def test_frontpage_sql_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + assert "abc123" in result.output + + def test_home_csv_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["home", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author" in result.output + + def test_home_sql_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["home", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + + def test_best_csv_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/top.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["best", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author" in result.output + + def test_best_sql_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/top.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["best", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + +class TestNavigationOutputFile: + """Test output file handling for navigation commands.""" + + def test_frontpage_csv_to_file(self, runner, mock_reddit_base, sample_navigation_response, tmp_path): + output_file = tmp_path / "output.csv" + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "csv", "--output", str(output_file)]) + assert result.exit_code == 0 + assert output_file.exists() + content = output_file.read_text(encoding="utf-8") + assert "abc123" in content + + def test_frontpage_sql_to_file(self, runner, mock_reddit_base, sample_navigation_response, tmp_path): + output_file = tmp_path / "output.sql" + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "sql", "--output", str(output_file)]) + assert result.exit_code == 0 + assert output_file.exists() + content = output_file.read_text(encoding="utf-8") + assert "INSERT INTO posts" in content + + def test_frontpage_xlsx_requires_output(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "--output is required for xlsx format" in result.output + + def test_frontpage_xlsx_to_file(self, runner, mock_reddit_base, sample_navigation_response, tmp_path): + output_file = tmp_path / "output.xlsx" + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "xlsx", "--output", str(output_file)]) + assert result.exit_code == 0 + assert output_file.exists() + assert "Exported" in result.output + +class TestNavigationValidation: + """Test validation errors for navigation commands.""" + + def test_frontpage_invalid_sort(self, runner, mock_reddit_base, sample_navigation_response): + result = runner.invoke(app, ["frontpage", "--sort", "invalid_sort"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_sort'" in result.output + assert "hot" in result.output + + def test_frontpage_invalid_period(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/top.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--sort", "top", "--period", "invalid_period"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_period'" in result.output + assert "day" in result.output + + def test_frontpage_limit_zero(self, runner, mock_reddit_base, sample_navigation_response): + result = runner.invoke(app, ["frontpage", "--limit", "0"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_frontpage_limit_negative(self, runner, mock_reddit_base, sample_navigation_response): + result = runner.invoke(app, ["frontpage", "--limit", "-5"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_frontpage_limit_over_100(self, runner, mock_reddit_base, sample_navigation_response): + result = runner.invoke(app, ["frontpage", "--limit", "101"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_best_invalid_period(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/top.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["best", "--period", "invalid"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid'" in result.output + + def test_home_invalid_sort(self, runner, mock_reddit_base, sample_navigation_response): + result = runner.invoke(app, ["home", "--sort", "bad_sort"]) + assert result.exit_code == 2 + assert "Invalid value 'bad_sort'" in result.output + +class TestNavigationAllSortValues: + """Test all valid sort values for frontpage/home.""" + + @pytest.mark.parametrize("sort_value", ["hot", "new", "top", "rising", "controversial", "gilded"]) + def test_frontpage_all_sort_values(self, runner, mock_reddit_base, sample_navigation_response, sort_value): + mock_reddit_base.get(f"/r/reddit/{sort_value}.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--sort", sort_value]) + assert result.exit_code == 0 + + +class TestNavigationAllPeriodValues: + """Test all valid period values.""" + + @pytest.mark.parametrize("period_value", ["day", "week", "month", "year", "all"]) + def test_best_all_period_values(self, runner, mock_reddit_base, sample_navigation_response, period_value): + mock_reddit_base.get("/r/reddit/top.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["best", "--period", period_value]) + assert result.exit_code == 0 + +class TestNavigationEmptyResults: + """Test navigation commands with empty results.""" + + def test_frontpage_empty_results(self, runner, mock_reddit_base, empty_posts_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=empty_posts_response) + ) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 0 + + def test_frontpage_empty_csv_output(self, runner, mock_reddit_base, empty_posts_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=empty_posts_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in result.output + + def test_frontpage_empty_sql_output(self, runner, mock_reddit_base, empty_posts_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=empty_posts_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "sql"]) + assert result.exit_code == 0 + +class TestNavigationErrors: + """Test error handling for navigation commands.""" + + def test_frontpage_404_error(self, runner, mock_reddit_base, error_response_404): + mock_reddit_base.get("/r/reddit/hot.json").mock(return_value=error_response_404) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 1 + assert "Not Found" in result.output or "Resource not found" in result.output + + def test_frontpage_500_error(self, runner, mock_reddit_base, error_response_500): + mock_reddit_base.get("/r/reddit/hot.json").mock(return_value=error_response_500) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 1 + assert "server error" in result.output.lower() or "Internal Server Error" in result.output + + def test_frontpage_403_error(self, runner, mock_reddit_base, error_response_403): + mock_reddit_base.get("/r/reddit/hot.json").mock(return_value=error_response_403) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 1 + assert "Authentication" in result.output or "Forbidden" in result.output + + def test_frontpage_429_error(self, runner, mock_reddit_base, error_response_429): + mock_reddit_base.get("/r/reddit/hot.json").mock(return_value=error_response_429) + result = runner.invoke(app, ["frontpage"]) + assert result.exit_code == 1 + assert "Rate limit" in result.output or "Too Many Requests" in result.output + + def test_home_404_error(self, runner, mock_reddit_base, error_response_404): + mock_reddit_base.get("/r/reddit/hot.json").mock(return_value=error_response_404) + result = runner.invoke(app, ["home"]) + assert result.exit_code == 1 + + def test_best_404_error(self, runner, mock_reddit_base, error_response_404): + mock_reddit_base.get("/r/reddit/top.json").mock(return_value=error_response_404) + result = runner.invoke(app, ["best"]) + assert result.exit_code == 1 + +class TestNavigationInvalidFormat: + """Test invalid format values.""" + + def test_frontpage_invalid_format(self, runner, mock_reddit_base, sample_navigation_response): + mock_reddit_base.get("/r/reddit/hot.json").mock( + return_value=httpx.Response(200, json=sample_navigation_response) + ) + result = runner.invoke(app, ["frontpage", "--format", "invalid_format"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_format'" in result.output + assert "display" in result.output diff --git a/tests/test_cli_post.py b/tests/test_cli_post.py index 30d5cdb..10ebe07 100644 --- a/tests/test_cli_post.py +++ b/tests/test_cli_post.py @@ -1,4 +1,6 @@ """Tests for post commands: post, --view, --info, --duplicates.""" +import os +import tempfile import pytest import httpx from typer.testing import CliRunner @@ -77,6 +79,168 @@ def test_post_missing_id(self, runner: CliRunner): assert result.exit_code != 0 + + +@pytest.fixture +def sample_post_empty_selftext() -> dict: + """Sample post response with empty selftext (link post).""" + return { + "data": { + "children": [ + { + "kind": "t3", + "data": { + "id": "xyz789", + "title": "Link Post Title", + "score": 42, + "num_comments": 10, + "author": "linkuser", + "subreddit": "links", + "url": "https://example.com/article", + "permalink": "/r/links/comments/xyz789/link_post/", + "selftext": "", + "created_utc": 1704067200, + }, + } + ] + } + } + + +class TestPost: + """Test suite for post command.""" + + def test_post_invalid_format(self, runner: CliRunner, mock_reddit_base): + """post with invalid format should exit with code 2.""" + result = runner.invoke(app, ["post", "abc123", "--format", "invalid"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid' for --format" in result.output + + def test_post_display_format_shows_all_fields( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post display format should show all post fields.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + result = runner.invoke(app, ["post", "abc123", "--format", "display"]) + assert result.exit_code == 0 + assert "Amazing New Feature Released" in result.output + assert "1337" in result.output # score + assert "256" in result.output # num_comments + assert "developer" in result.output + assert "r/programming" in result.output + assert "https://github.com/example/repo" in result.output + + def test_post_display_format_empty_selftext( + self, runner: CliRunner, mock_reddit_base, sample_post_empty_selftext + ): + """post display format with empty selftext should not print empty content.""" + mock_reddit_base.get("/by_id/t3_xyz789.json").mock( + return_value=httpx.Response(200, json=sample_post_empty_selftext) + ) + result = runner.invoke(app, ["post", "xyz789", "--format", "display"]) + assert result.exit_code == 0 + # Should still show post info but not print empty selftext + assert "Link Post Title" in result.output + + def test_post_sql_format_to_stdout( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post sql format should output SQL INSERT to stdout.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + result = runner.invoke(app, ["post", "abc123", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + assert "abc123" in result.output + assert "Amazing New Feature Released" in result.output + + def test_post_sql_format_to_file( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post sql format with --output should write to file.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".sql", delete=False) as f: + temp_path = f.name + try: + result = runner.invoke(app, ["post", "abc123", "--format", "sql", "--output", temp_path]) + assert result.exit_code == 0 + assert f"Exported 1 post to {temp_path}" in result.output + with open(temp_path, "r") as f: + file_content = f.read() + assert "INSERT INTO posts" in file_content + assert "abc123" in file_content + finally: + os.unlink(temp_path) + + def test_post_csv_format_to_stdout( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post csv format should output CSV to stdout.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + result = runner.invoke(app, ["post", "abc123", "--format", "csv"]) + assert result.exit_code == 0 + # Should have header and one row + lines = result.output.strip().split("\n") + assert len(lines) == 2 + assert "id,title,author" in lines[0] + assert "abc123" in lines[1] + + def test_post_csv_format_to_file( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post csv format with --output should write to file.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + temp_path = f.name + try: + result = runner.invoke(app, ["post", "abc123", "--format", "csv", "--output", temp_path]) + assert result.exit_code == 0 + assert f"Exported 1 post to {temp_path}" in result.output + with open(temp_path, "r") as f: + file_content = f.read() + assert "id,title,author" in file_content + assert "abc123" in file_content + finally: + os.unlink(temp_path) + + def test_post_xlsx_format_with_output( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post xlsx format with --output should write xlsx file.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + temp_path = f.name + try: + result = runner.invoke(app, ["post", "abc123", "--format", "xlsx", "--output", temp_path]) + assert result.exit_code == 0 + assert f"Exported 1 post to {temp_path}" in result.output + # Verify file was created and has content + assert os.path.getsize(temp_path) > 0 + finally: + os.unlink(temp_path) + + def test_post_xlsx_format_without_output( + self, runner: CliRunner, mock_reddit_base, sample_post_response + ): + """post xlsx format without --output should exit with code 2.""" + mock_reddit_base.get("/by_id/t3_abc123.json").mock( + return_value=httpx.Response(200, json=sample_post_response) + ) + result = runner.invoke(app, ["post", "abc123", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "--output is required for xlsx format" in result.output + class TestPostView: """Test suite for post --view command.""" @@ -121,3 +285,69 @@ def test_info_output_contains_title( assert "Amazing New Feature Released" in result.output + + +class TestPostErrors: + """Test suite for post command error handling.""" + + def test_post_api_error_404( + self, runner: CliRunner, mock_reddit_base, error_response_404 + ): + """post with 404 API error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_notfound.json").mock( + return_value=error_response_404 + ) + result = runner.invoke(app, ["post", "notfound"]) + assert result.exit_code == 1 + assert "Resource not found" in result.output + + def test_post_api_error_403( + self, runner: CliRunner, mock_reddit_base, error_response_403 + ): + """post with 403 API error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_forbidden.json").mock( + return_value=error_response_403 + ) + result = runner.invoke(app, ["post", "forbidden"]) + assert result.exit_code == 1 + assert "Authentication required" in result.output + + def test_post_api_error_429( + self, runner: CliRunner, mock_reddit_base, error_response_429 + ): + """post with 429 API error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_ratelimit.json").mock( + return_value=error_response_429 + ) + result = runner.invoke(app, ["post", "ratelimit"]) + assert result.exit_code == 1 + assert "Rate limited" in result.output + + def test_post_api_error_500( + self, runner: CliRunner, mock_reddit_base, error_response_500 + ): + """post with 500 API error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_servererror.json").mock( + return_value=error_response_500 + ) + result = runner.invoke(app, ["post", "servererror"]) + assert result.exit_code == 1 + assert "Reddit server error" in result.output + + def test_post_api_error_timeout(self, runner: CliRunner, mock_reddit_base): + """post with timeout error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_timeout.json").mock( + side_effect=httpx.TimeoutException("Connection timed out") + ) + result = runner.invoke(app, ["post", "timeout"]) + assert result.exit_code == 1 + assert "Connection timed out" in result.output + + def test_post_api_error_connect(self, runner: CliRunner, mock_reddit_base): + """post with connection error should exit with code 1.""" + mock_reddit_base.get("/by_id/t3_connect.json").mock( + side_effect=httpx.ConnectError("Could not connect") + ) + result = runner.invoke(app, ["post", "connect"]) + assert result.exit_code == 1 + assert "Could not connect to Reddit" in result.output diff --git a/tests/test_cli_search.py b/tests/test_cli_search.py index 3c756ff..ff76575 100644 --- a/tests/test_cli_search.py +++ b/tests/test_cli_search.py @@ -1,4 +1,6 @@ """Tests for global search command.""" +import os +import tempfile import pytest import httpx from typer.testing import CliRunner @@ -25,8 +27,8 @@ def test_search_output_contains_results( return_value=httpx.Response(200, json=sample_search_response) ) result = runner.invoke(app, ["search", "python"]) - assert "How to learn Python" in result.output - assert "Best Python frameworks" in result.output + assert "How to learn Python in 2024" in result.output + assert "Best Python frameworks comparison" in result.output def test_search_output_contains_subreddits( self, runner: CliRunner, mock_reddit_base, sample_search_response @@ -85,3 +87,264 @@ def test_search_no_results(self, runner: CliRunner, mock_reddit_base, empty_post result = runner.invoke(app, ["search", "xyzzynonexistentquery12345"]) assert result.exit_code == 0 assert "No posts found" in result.output + # ===== Format option tests ===== + + def test_search_format_sql( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should output SQL format when --format sql is specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO posts" in result.output + assert "search1" in result.output + assert "search2" in result.output + + def test_search_format_csv( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should output CSV format when --format csv is specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python", "--format", "csv"]) + assert result.exit_code == 0 + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in result.output + assert "search1" in result.output + assert "search2" in result.output + + def test_search_format_display( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should output display format when --format display is specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python", "--format", "display"]) + assert result.exit_code == 0 + assert "[500]" in result.output + assert "How to learn Python in 2024" in result.output + + def test_search_format_xlsx_with_output( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should write xlsx file when --format xlsx and --output are specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["search", "python", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + assert os.path.getsize(output_path) > 0 + assert "Exported 2 posts" in result.output + finally: + os.unlink(output_path) + + def test_search_format_xlsx_without_output( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should fail when --format xlsx is specified without --output.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "--output is required for xlsx format" in result.output + + def test_search_format_sql_with_output( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should write SQL to file when --format sql and --output are specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["search", "python", "--format", "sql", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + with open(output_path, "r") as f: + content = f.read() + assert "INSERT INTO posts" in content + assert "Exported 2 posts" in result.output + finally: + os.unlink(output_path) + + def test_search_format_csv_with_output( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should write CSV to file when --format csv and --output are specified.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["search", "python", "--format", "csv", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + with open(output_path, "r") as f: + content = f.read() + assert "id,title,author,subreddit,score,num_comments,url,permalink,created_utc,selftext" in content + assert "Exported 2 posts" in result.output + finally: + os.unlink(output_path) + + def test_search_invalid_format(self, runner: CliRunner, mock_reddit_base): + """search should fail with invalid format value.""" + result = runner.invoke(app, ["search", "python", "--format", "invalid_format"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_format' for --format" in result.output + # ===== Limit validation tests ===== + + def test_search_limit_zero(self, runner: CliRunner, mock_reddit_base): + """search should fail when --limit is 0.""" + result = runner.invoke(app, ["search", "python", "--limit", "0"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_search_limit_negative(self, runner: CliRunner, mock_reddit_base): + """search should fail when --limit is negative.""" + result = runner.invoke(app, ["search", "python", "--limit", "-5"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + def test_search_limit_above_100(self, runner: CliRunner, mock_reddit_base): + """search should fail when --limit is above 100.""" + result = runner.invoke(app, ["search", "python", "--limit", "101"]) + assert result.exit_code == 2 + assert "--limit must be between 1 and 100" in result.output + + # ===== Sort validation tests ===== + + def test_search_invalid_sort(self, runner: CliRunner, mock_reddit_base): + """search should fail with invalid sort value.""" + result = runner.invoke(app, ["search", "python", "--sort", "invalid_sort"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_sort' for --sort" in result.output + + # ===== Period validation tests ===== + + def test_search_invalid_period(self, runner: CliRunner, mock_reddit_base): + """search should fail with invalid period value.""" + result = runner.invoke(app, ["search", "python", "--period", "invalid_period"]) + assert result.exit_code == 2 + assert "Invalid value 'invalid_period' for --period" in result.output + + # ===== Cursor display tests ===== + + def test_search_with_after_cursor( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should display after cursor when present.""" + sample_search_response["data"]["after"] = "t3_after123" + sample_search_response["data"]["before"] = None + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 0 + assert "After: t3_after123" in result.output + + def test_search_with_before_cursor( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should display before cursor when present.""" + sample_search_response["data"]["after"] = None + sample_search_response["data"]["before"] = "t3_before456" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 0 + assert "Before: t3_before456" in result.output + + def test_search_with_both_cursors( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should display both after and before cursors when both present.""" + sample_search_response["data"]["after"] = "t3_after123" + sample_search_response["data"]["before"] = "t3_before456" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 0 + assert "After: t3_after123" in result.output + assert "Before: t3_before456" in result.output + # ===== Error handling tests ===== + + def test_search_api_error_404(self, runner: CliRunner, mock_reddit_base, error_response_404): + """search should handle 404 API errors gracefully.""" + mock_reddit_base.get("/search.json").mock( + return_value=error_response_404 + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 1 + assert "Resource not found" in result.output + + def test_search_api_error_429(self, runner: CliRunner, mock_reddit_base, error_response_429): + """search should handle 429 rate limit errors gracefully.""" + mock_reddit_base.get("/search.json").mock( + return_value=error_response_429 + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 1 + assert "Rate limited" in result.output + + def test_search_api_error_500(self, runner: CliRunner, mock_reddit_base, error_response_500): + """search should handle 500 server errors gracefully.""" + mock_reddit_base.get("/search.json").mock( + return_value=error_response_500 + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 1 + assert "server error" in result.output + + def test_search_api_error_403(self, runner: CliRunner, mock_reddit_base, error_response_403): + """search should handle 403 authentication errors gracefully.""" + mock_reddit_base.get("/search.json").mock( + return_value=error_response_403 + ) + result = runner.invoke(app, ["search", "python"]) + assert result.exit_code == 1 + assert "Authentication required" in result.output + + # ===== Combined parameter tests ===== + + def test_search_all_params_combined( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should accept all parameters combined.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, [ + "search", "python", + "--sort", "top", + "--limit", "10", + "--period", "week", + "--format", "display" + ]) + assert result.exit_code == 0 + + def test_search_format_csv_combined_with_period( + self, runner: CliRunner, mock_reddit_base, sample_search_response + ): + """search should work with csv format and period option.""" + mock_reddit_base.get("/search.json").mock( + return_value=httpx.Response(200, json=sample_search_response) + ) + result = runner.invoke(app, [ + "search", "python", + "--format", "csv", + "--period", "month" + ]) + assert result.exit_code == 0 + assert "id,title,author,subreddit" in result.output \ No newline at end of file diff --git a/tests/test_cli_subreddit.py b/tests/test_cli_subreddit.py index 5b3f21f..57d63ea 100644 --- a/tests/test_cli_subreddit.py +++ b/tests/test_cli_subreddit.py @@ -1,294 +1,440 @@ -"""Tests for subreddit commands: subreddit, subreddits, subreddits --search/--new/--gold/--default.""" +"""Tests for subreddit commands.""" +import os +import tempfile import pytest import httpx from typer.testing import CliRunner - from reddit_cli import app - @pytest.fixture def sample_subreddit_response() -> dict: - """Sample subreddit info response.""" return { "data": { - "id": "2qh13", - "name": "t5_python", - "display_name": "python", - "title": "Python Programming", - "description": "Python programming discussion", - "subscribers": 1500000, - "accounts_active": 25000, - "over_18": False, + "id": "2qh13", "name": "t5_python", "display_name": "python", + "title": "Python Programming", "description": "Python programming discussion", + "subscribers": 1500000, "accounts_active": 25000, "over_18": False, } } - @pytest.fixture def sample_subreddits_list_response() -> dict: - """Sample subreddits list response.""" return { "data": { "children": [ - { - "kind": "t5", - "data": { - "id": "2qh13", - "name": "t5_python", - "display_name": "python", - "title": "Python Programming", - "description": "Python programming discussion", - "subscribers": 1500000, - "accounts_active": 25000, - }, - }, - { - "kind": "t5", - "data": { - "id": "2qh16", - "name": "t5_programming", - "display_name": "programming", - "title": "Programming", - "description": "Programming discussions", - "subscribers": 1000000, - "accounts_active": 15000, - }, - }, + {"kind": "t5", "data": {"id": "2qh13", "name": "t5_python", "display_name": "python", + "title": "Python Programming", "description": "Python programming discussion", + "subscribers": 1500000, "accounts_active": 25000}}, + {"kind": "t5", "data": {"id": "2qh16", "name": "t5_programming", "display_name": "programming", + "title": "Programming", "description": "Programming discussions", + "subscribers": 1000000, "accounts_active": 15000}}, ] } } - @pytest.fixture def sample_rules_response() -> dict: - """Sample subreddit rules response.""" - return { - "rules": [ - {"short_name": "Be respectful", "description": "Treat others with respect"}, - {"short_name": "No spam", "description": "Don't spam"}, - ] - } - + return {"rules": [ + {"short_name": "Be respectful", "description": "Treat others with respect"}, + {"short_name": "No spam", "description": "Don't spam"}, + ]} class TestSubreddit: - """Test suite for subreddit command.""" - - def test_subreddit_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddit_response - ): - """subreddit should exit with code 0.""" - mock_reddit_base.get("/r/python/about.json").mock( - return_value=httpx.Response(200, json=sample_subreddit_response) - ) + def test_subreddit_exit_code(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) result = runner.invoke(app, ["subreddit", "python"]) assert result.exit_code == 0 - def test_subreddit_output_contains_title( - self, runner: CliRunner, mock_reddit_base, sample_subreddit_response - ): - """subreddit output should contain subreddit title.""" - mock_reddit_base.get("/r/python/about.json").mock( - return_value=httpx.Response(200, json=sample_subreddit_response) - ) + def test_subreddit_output_contains_title(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) result = runner.invoke(app, ["subreddit", "python"]) assert "Python Programming" in result.output - def test_subreddit_output_contains_name( - self, runner: CliRunner, mock_reddit_base, sample_subreddit_response - ): - """subreddit output should contain subreddit name.""" - mock_reddit_base.get("/r/python/about.json").mock( - return_value=httpx.Response(200, json=sample_subreddit_response) - ) + def test_subreddit_output_contains_name(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) result = runner.invoke(app, ["subreddit", "python"]) assert "r/python" in result.output - def test_subreddit_with_rules_flag( - self, runner: CliRunner, mock_reddit_base, sample_subreddit_response, sample_rules_response - ): - """subreddits rules python should show rules.""" - mock_reddit_base.get("/r/python/about.json").mock( - return_value=httpx.Response(200, json=sample_subreddit_response) - ) - mock_reddit_base.get("/r/python/about/rules.json").mock( - return_value=httpx.Response(200, json=sample_rules_response) - ) + def test_subreddit_with_rules_flag(self, runner, mock_reddit_base, sample_subreddit_response, sample_rules_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + mock_reddit_base.get("/r/python/about/rules.json").mock(httpx.Response(200, json=sample_rules_response)) result = runner.invoke(app, ["subreddits", "rules", "python"]) assert result.exit_code == 0 assert "Be respectful" in result.output - def test_subreddit_missing_name(self, runner: CliRunner): - """subreddit should fail without name.""" + def test_subreddit_missing_name(self, runner): result = runner.invoke(app, ["subreddit"]) assert result.exit_code != 0 - def test_subreddit_with_r_prefix(self, runner: CliRunner, mock_reddit_base, sample_subreddit_response): - """subreddit should accept name with r/ prefix.""" - mock_reddit_base.get("/r/python/about.json").mock( - return_value=httpx.Response(200, json=sample_subreddit_response) - ) + def test_subreddit_with_r_prefix(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) result = runner.invoke(app, ["subreddit", "r/python"]) assert result.exit_code == 0 -class TestSubreddits: - """Test suite for subreddits popular command.""" + def test_subreddit_invalid_format(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + result = runner.invoke(app, ["subreddit", "python", "--format", "invalid"]) + assert result.exit_code != 0 + + def test_subreddit_format_sql(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + result = runner.invoke(app, ["subreddit", "python", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output + + def test_subreddit_format_csv(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + result = runner.invoke(app, ["subreddit", "python", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_subreddit_format_xlsx_requires_output(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + result = runner.invoke(app, ["subreddit", "python", "--format", "xlsx"]) + assert result.exit_code == 2 + assert "output is required" in result.output.lower() + + def test_subreddit_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddit_response): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(200, json=sample_subreddit_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddit", "python", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + assert "Exported" in result.output + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_subreddit_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get("/r/python/about.json").mock(httpx.Response(404, json={"error": "Not found"})) + result = runner.invoke(app, ["subreddit", "python"]) + assert result.exit_code != 0 - def test_subreddits_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits popular should exit with code 0.""" + +class TestSubreddits: + def test_subreddits_exit_code(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "popular"]) assert result.exit_code == 0 - def test_subreddits_output_contains_names( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits popular output should contain subreddit names.""" + def test_subreddits_output_contains_names(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "popular"]) assert "r/python" in result.output assert "r/programming" in result.output - def test_subreddits_with_limit( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits popular should accept --limit option.""" + def test_subreddits_with_limit(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits.json", params={"limit": 5, "sort": "subscribers"}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "popular", "--limit", "5"]) assert result.exit_code == 0 - def test_subreddits_with_sort( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits popular should accept --sort option.""" + def test_subreddits_with_sort(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "gilded"}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "popular", "--sort", "gilded"]) assert result.exit_code == 0 - def test_subreddits_empty_results( - self, runner: CliRunner, mock_reddit_base, empty_posts_response - ): - """subreddits popular should handle empty results gracefully.""" + def test_subreddits_empty_results(self, runner, mock_reddit_base, empty_posts_response): mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( - return_value=httpx.Response(200, json=empty_posts_response) - ) + httpx.Response(200, json=empty_posts_response)) result = runner.invoke(app, ["subreddits", "popular"]) assert result.exit_code == 0 + def test_subreddits_invalid_sort(self, runner): + result = runner.invoke(app, ["subreddits", "popular", "--sort", "invalid_sort"]) + assert result.exit_code != 0 -class TestSubredditsSearch: - """Test suite for subreddits search command.""" + def test_subreddits_invalid_limit_zero(self, runner): + result = runner.invoke(app, ["subreddits", "popular", "--limit", "0"]) + assert result.exit_code == 2 + + def test_subreddits_invalid_limit_too_high(self, runner): + result = runner.invoke(app, ["subreddits", "popular", "--limit", "101"]) + assert result.exit_code == 2 - def test_search_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits search should exit with code 0.""" + def test_subreddits_invalid_format(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "popular", "--format", "invalid"]) + assert result.exit_code != 0 + + def test_subreddits_popular_format_sql(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "popular", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output + + def test_subreddits_popular_format_csv(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "popular", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_subreddits_popular_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddits", "popular", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_subreddits_popular_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get(url="/subreddits.json", params={"limit": 25, "sort": "subscribers"}).mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "popular"]) + assert result.exit_code != 0 + + +class TestSubredditsSearch: + def test_search_exit_code(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "search", "python"]) assert result.exit_code == 0 - def test_search_output_contains_query( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits search output should mention the query.""" + def test_search_output_contains_query(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/search.json", params={"q": "programming", "limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "search", "programming"]) assert "programming" in result.output.lower() - def test_search_missing_query(self, runner: CliRunner): - """subreddits search should fail without query.""" + def test_search_missing_query(self, runner): result = runner.invoke(app, ["subreddits", "search"]) assert result.exit_code != 0 - def test_search_no_results(self, runner: CliRunner, mock_reddit_base, empty_posts_response): - """subreddits search should handle empty results.""" + def test_search_no_results(self, runner, mock_reddit_base, empty_posts_response): mock_reddit_base.get(url="/subreddits/search.json", params={"q": "nonexistent123xyz", "limit": 25}).mock( - return_value=httpx.Response(200, json=empty_posts_response) - ) + httpx.Response(200, json=empty_posts_response)) result = runner.invoke(app, ["subreddits", "search", "nonexistent123xyz"]) assert result.exit_code == 0 assert "No subreddits found" in result.output + def test_search_invalid_format(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "search", "python", "--format", "invalid"]) + assert result.exit_code != 0 -class TestSubredditsNew: - """Test suite for subreddits new command.""" + def test_search_format_sql(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "search", "python", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output - def test_new_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits new should exit with code 0.""" + def test_search_format_csv(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "search", "python", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_search_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddits", "search", "python", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_search_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get(url="/subreddits/search.json", params={"q": "python", "limit": 25}).mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "search", "python"]) + assert result.exit_code != 0 + + +class TestSubredditsNew: + def test_new_exit_code(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "new"]) assert result.exit_code == 0 - def test_new_with_limit( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits new should accept --limit option.""" + def test_new_with_limit(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 10}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "new", "--limit", "10"]) assert result.exit_code == 0 + def test_new_invalid_format(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "new", "--format", "invalid"]) + assert result.exit_code != 0 + + def test_new_format_sql(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "new", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output + + def test_new_format_csv(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "new", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_new_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddits", "new", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_new_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get(url="/subreddits/new.json", params={"limit": 25}).mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "new"]) + assert result.exit_code != 0 -class TestSubredditsGold: - """Test suite for subreddits gold command.""" - def test_gold_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits gold should exit with code 0.""" +class TestSubredditsGold: + def test_gold_exit_code(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "gold"]) assert result.exit_code == 0 - def test_gold_output_contains_subreddits( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits gold output should contain subreddit names.""" + def test_gold_output_contains_subreddits(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "gold"]) assert "python" in result.output + def test_gold_invalid_format(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "gold", "--format", "invalid"]) + assert result.exit_code != 0 -class TestSubredditsDefault: - """Test suite for subreddits default command.""" + def test_gold_format_sql(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "gold", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output - def test_default_exit_code( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits default should exit with code 0.""" + def test_gold_format_csv(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "gold", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_gold_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddits", "gold", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_gold_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get(url="/subreddits/gold.json", params={"limit": 25}).mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "gold"]) + assert result.exit_code != 0 + + +class TestSubredditsDefault: + def test_default_exit_code(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "default"]) assert result.exit_code == 0 - def test_default_output_contains_subreddits( - self, runner: CliRunner, mock_reddit_base, sample_subreddits_list_response - ): - """subreddits default output should contain subreddit names.""" + def test_default_output_contains_subreddits(self, runner, mock_reddit_base, sample_subreddits_list_response): mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( - return_value=httpx.Response(200, json=sample_subreddits_list_response) - ) + httpx.Response(200, json=sample_subreddits_list_response)) result = runner.invoke(app, ["subreddits", "default"]) assert "python" in result.output + + def test_default_invalid_format(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "default", "--format", "invalid"]) + assert result.exit_code != 0 + + def test_default_format_sql(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "default", "--format", "sql"]) + assert result.exit_code == 0 + assert "INSERT INTO subreddits" in result.output + + def test_default_format_csv(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + result = runner.invoke(app, ["subreddits", "default", "--format", "csv"]) + assert result.exit_code == 0 + assert "display_name,title,description" in result.output + + def test_default_format_xlsx_with_output(self, runner, mock_reddit_base, sample_subreddits_list_response): + mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( + httpx.Response(200, json=sample_subreddits_list_response)) + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + output_path = f.name + try: + result = runner.invoke(app, ["subreddits", "default", "--format", "xlsx", "--output", output_path]) + assert result.exit_code == 0 + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.unlink(output_path) + + def test_default_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get(url="/subreddits/default.json", params={"limit": 25}).mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "default"]) + assert result.exit_code != 0 + + +class TestSubredditsRules: + def test_rules_exit_code(self, runner, mock_reddit_base, sample_rules_response): + mock_reddit_base.get("/r/python/about/rules.json").mock( + httpx.Response(200, json=sample_rules_response)) + result = runner.invoke(app, ["subreddits", "rules", "python"]) + assert result.exit_code == 0 + + def test_rules_output_contains_rule_names(self, runner, mock_reddit_base, sample_rules_response): + mock_reddit_base.get("/r/python/about/rules.json").mock( + httpx.Response(200, json=sample_rules_response)) + result = runner.invoke(app, ["subreddits", "rules", "python"]) + assert "Be respectful" in result.output + assert "No spam" in result.output + + def test_rules_api_error(self, runner, mock_reddit_base): + mock_reddit_base.get("/r/python/about/rules.json").mock( + httpx.Response(500, json={"error": "Internal Server Error"})) + result = runner.invoke(app, ["subreddits", "rules", "python"]) + assert result.exit_code != 0 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..ee3b203 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,156 @@ +"""Tests for reddit_cli.errors module.""" +import pytest +import httpx +import typer +from unittest.mock import MagicMock, patch + +from reddit_cli.errors import ( + handle_api_error, + handle_validation_error, + handle_interrupt, + EXIT_GENERAL_ERROR, + EXIT_USAGE_ERROR, + EXIT_INTERRUPTED, +) + + +class TestHandleApiError: + def test_timeout_exception(self): + exc = httpx.TimeoutException("Connection timed out") + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_connect_error(self): + exc = httpx.ConnectError("Could not connect") + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_400(self): + response = MagicMock() + response.status_code = 400 + exc = httpx.HTTPStatusError("Bad Request", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_401(self): + response = MagicMock() + response.status_code = 401 + exc = httpx.HTTPStatusError("Unauthorized", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_403(self): + response = MagicMock() + response.status_code = 403 + exc = httpx.HTTPStatusError("Forbidden", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_404(self): + response = MagicMock() + response.status_code = 404 + exc = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_429(self): + response = MagicMock() + response.status_code = 429 + exc = httpx.HTTPStatusError("Too Many Requests", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_500(self): + response = MagicMock() + response.status_code = 500 + exc = httpx.HTTPStatusError("Internal Server Error", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_502(self): + response = MagicMock() + response.status_code = 502 + exc = httpx.HTTPStatusError("Bad Gateway", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_503(self): + response = MagicMock() + response.status_code = 503 + exc = httpx.HTTPStatusError("Service Unavailable", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_http_status_error_other(self): + response = MagicMock() + response.status_code = 418 + exc = httpx.HTTPStatusError("I am a teapot", request=MagicMock(), response=response) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + def test_typer_exit_reraised(self): + exc = typer.Exit(code=42) + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == 42 + + def test_generic_exception(self): + exc = ValueError("Something went wrong") + with pytest.raises(typer.Exit) as exc_info: + handle_api_error(exc) + assert exc_info.value.exit_code == EXIT_GENERAL_ERROR + + +class TestHandleValidationError: + def test_single_valid_value(self): + with pytest.raises(typer.Exit) as exc_info: + handle_validation_error("sort", ["hot"], "invalid") + assert exc_info.value.exit_code == EXIT_USAGE_ERROR + + def test_multiple_valid_values(self): + with pytest.raises(typer.Exit) as exc_info: + handle_validation_error("sort", ["hot", "new", "top", "rising"], "invalid") + assert exc_info.value.exit_code == EXIT_USAGE_ERROR + + def test_empty_valid_values(self): + with pytest.raises(typer.Exit) as exc_info: + handle_validation_error("sort", [], "invalid") + assert exc_info.value.exit_code == EXIT_USAGE_ERROR + + def test_special_characters_in_value(self): + with pytest.raises(typer.Exit) as exc_info: + handle_validation_error("sort", ["hot", "new"], "invalid