| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import pytest |
| |
| from apache_iggy import IggyClient, SendMessage |
| |
| from .utils import get_server_config, wait_for_ping, wait_for_server |
| |
| |
| class TestStreamOperations: |
| """Test stream creation, retrieval, and management.""" |
| |
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| ("prefix", "min_bytes", "max_bytes"), |
| [ |
| ("a", 8, 255), |
| ("stream-name", 8, 255), |
| ("stream_name.with.mixed-CHARS123", 8, 255), |
| (" leading-space", 8, 255), |
| ("trailing-space ", 8, 255), |
| ("multiple spaces inside", 8, 255), |
| ("name/with/slash", 8, 255), |
| ("name:with:colons", 8, 255), |
| ("name.with.dots", 8, 255), |
| (" ", 8, 255), |
| ("a" * 247, 255, 255), |
| (("é" * 122) + "abc", 255, 255), |
| (("한" * 81) + "abc", 255, 255), |
| (("漢" * 81) + "abc", 255, 255), |
| (("あ" * 81) + "abc", 255, 255), |
| (("😀" * 60) + "abcdefg", 255, 255), |
| ], |
| ) |
| async def test_create_and_get_stream( |
| self, |
| iggy_client: IggyClient, |
| unique_name, |
| prefix: str, |
| min_bytes: int, |
| max_bytes: int, |
| ): |
| """Test stream creation and retrieval.""" |
| stream_name = unique_name(prefix, min_bytes=min_bytes, max_bytes=max_bytes) |
| |
| await iggy_client.create_stream(stream_name) |
| |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| assert stream.created_at > 0 |
| assert stream.name == stream_name |
| assert stream.size == 0 |
| assert stream.topics_count == 0 |
| |
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| ("prefix", "min_bytes", "max_bytes"), |
| [ |
| ("", 0, 0), |
| ("a" * 248, 256, 256), |
| ("é" * 124, 256, 256), |
| (("é" * 123) + "ab", 256, 256), |
| (("한" * 82) + "ab", 256, 256), |
| (("漢" * 82) + "ab", 256, 256), |
| (("あ" * 82) + "ab", 256, 256), |
| ("😀" * 62, 256, 256), |
| (("😀" * 61) + "abcd", 256, 256), |
| ], |
| ) |
| async def test_create_stream_invalid_names( |
| self, |
| iggy_client: IggyClient, |
| unique_name, |
| prefix: str, |
| min_bytes: int, |
| max_bytes: int, |
| ): |
| """Test create_stream enforces byte-length validation.""" |
| stream_name = unique_name(prefix, min_bytes=min_bytes, max_bytes=max_bytes) |
| |
| with pytest.raises(RuntimeError): |
| await iggy_client.create_stream(stream_name) |
| |
| @pytest.mark.asyncio |
| async def test_get_stream_by_name_and_id( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test repeated stream lookup works by both name and numeric id.""" |
| stream_name = unique_name() |
| await iggy_client.create_stream(stream_name) |
| |
| stream_by_name = await iggy_client.get_stream(stream_name) |
| assert stream_by_name is not None |
| |
| stream_by_name_again = await iggy_client.get_stream(stream_name) |
| assert stream_by_name_again is not None |
| assert stream_by_name_again.id == stream_by_name.id |
| assert stream_by_name_again.name == stream_by_name.name |
| |
| stream_by_id = await iggy_client.get_stream(stream_by_name.id) |
| assert stream_by_id is not None |
| assert stream_by_id.id == stream_by_name.id |
| assert stream_by_id.name == stream_by_name.name |
| |
| @pytest.mark.asyncio |
| async def test_create_stream_then_reconnect_then_get_stream( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test a stream remains retrievable after reconnecting with a fresh client.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| await client.connect() |
| await wait_for_ping(client) |
| await client.login_user("iggy", "iggy") |
| |
| stream = await client.get_stream(stream_name) |
| assert stream is not None |
| assert stream.name == stream_name |
| assert stream.topics_count == 0 |
| |
| @pytest.mark.asyncio |
| async def test_duplicate_stream_creation( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test that creating duplicate streams raises appropriate errors.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| with pytest.raises(RuntimeError) as exc_info: |
| await iggy_client.create_stream(stream_name) |
| |
| assert "already exists" in str(exc_info.value) |
| |
| @pytest.mark.asyncio |
| async def test_get_nonexistent_stream(self, iggy_client: IggyClient, unique_name): |
| """Test getting a non-existent stream by name or numeric id.""" |
| nonexistent_name = unique_name() |
| |
| stream_by_name = await iggy_client.get_stream(nonexistent_name) |
| assert stream_by_name is None |
| |
| stream_by_id = await iggy_client.get_stream(999999) |
| assert stream_by_id is None |
| |
| @pytest.mark.asyncio |
| async def test_get_stream_before_connect_fails(self, unique_name): |
| """Test get_stream requires an established connection.""" |
| host, port = get_server_config() |
| client = IggyClient(f"{host}:{port}") |
| |
| with pytest.raises(RuntimeError): |
| await client.get_stream(unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_get_stream_before_login_fails(self, unique_name): |
| """Test get_stream requires authentication.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| await client.connect() |
| |
| with pytest.raises(RuntimeError): |
| await client.get_stream(unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_create_stream_before_connect_fails(self, unique_name): |
| """Test create_stream requires an established connection.""" |
| host, port = get_server_config() |
| client = IggyClient(f"{host}:{port}") |
| |
| with pytest.raises(RuntimeError): |
| await client.create_stream(unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_create_stream_before_login_fails(self, unique_name): |
| """Test create_stream requires authentication.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| await client.connect() |
| |
| with pytest.raises(RuntimeError): |
| await client.create_stream(unique_name()) |
| |
| |
| class TestGetStreams: |
| """Test listing streams via get_streams.""" |
| |
| @pytest.mark.asyncio |
| async def test_get_streams_returns_created_streams( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test get_streams returns every stream created during the test.""" |
| # Stream IDs can be reused after deletion, so creation order does not |
| # imply numeric ID order. The client fixture is session-scoped, so |
| # other tests may have created streams; assert on the ones created |
| # here instead of the full server view. |
| created = [unique_name(f"z{index}") for index in range(3, 0, -1)] |
| for name in created: |
| await iggy_client.create_stream(name) |
| |
| streams = await iggy_client.get_streams() |
| created_names = set(created) |
| mine = [stream for stream in streams if stream.name in created_names] |
| |
| assert {stream.name for stream in mine} == created_names |
| assert [stream.id for stream in mine] == sorted(stream.id for stream in mine) |
| assert all(stream.created_at > 0 for stream in mine) |
| assert all(stream.size == 0 for stream in mine) |
| assert all(stream.messages_count == 0 for stream in mine) |
| assert all(stream.topics_count == 0 for stream in mine) |
| |
| @pytest.mark.asyncio |
| async def test_get_streams_reflects_topic_count( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test get_streams reports the topic count for a listed stream.""" |
| stream_name = unique_name() |
| topic_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| await iggy_client.create_topic( |
| stream=stream_name, name=topic_name, partitions_count=1 |
| ) |
| |
| streams = await iggy_client.get_streams() |
| listed = next( |
| (stream for stream in streams if stream.name == stream_name), None |
| ) |
| assert listed is not None |
| assert listed.topics_count == 1 |
| |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| assert [topic.name for topic in stream.topics] == [topic_name] |
| |
| @pytest.mark.asyncio |
| async def test_get_streams_returns_same_result_when_called_repeatedly( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test repeated get_streams calls return an identically ordered view.""" |
| await iggy_client.create_stream(unique_name()) |
| |
| first = await iggy_client.get_streams() |
| second = await iggy_client.get_streams() |
| assert [stream.id for stream in first] == [stream.id for stream in second] |
| assert [stream.name for stream in first] == [stream.name for stream in second] |
| |
| @pytest.mark.asyncio |
| async def test_get_streams_requires_connection_and_auth(self): |
| """Test get_streams fails both before connecting and before logging in.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| with pytest.raises(RuntimeError): |
| await client.get_streams() |
| |
| await client.connect() |
| with pytest.raises(RuntimeError): |
| await client.get_streams() |
| |
| |
| class TestUpdateStream: |
| """Test updating streams via update_stream.""" |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_renames_stream( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test update_stream renames a stream; old name no longer resolves.""" |
| stream_name = unique_name() |
| new_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| before = await iggy_client.get_stream(stream_name) |
| assert before is not None |
| |
| await iggy_client.update_stream(stream_id=stream_name, name=new_name) |
| |
| renamed = await iggy_client.get_stream(new_name) |
| assert renamed is not None |
| assert renamed.name == new_name |
| assert renamed.id == before.id |
| |
| old = await iggy_client.get_stream(stream_name) |
| assert old is None |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_by_numeric_id( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test update_stream accepts a numeric stream id.""" |
| stream_name = unique_name() |
| new_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| |
| await iggy_client.update_stream(stream_id=stream.id, name=new_name) |
| |
| renamed = await iggy_client.get_stream(new_name) |
| assert renamed is not None |
| assert renamed.name == new_name |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_forwards_options( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test update_stream forwards option keys to the server.""" |
| stream_name = unique_name() |
| await iggy_client.create_stream(stream_name) |
| |
| with pytest.raises(RuntimeError): |
| await iggy_client.update_stream( |
| stream_id=stream_name, |
| name=stream_name, |
| options={"unknown": "value"}, |
| ) |
| |
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| "new_name", |
| [ |
| pytest.param("a", id="one-byte"), |
| pytest.param("a" * 255, id="255-byte-ascii"), |
| pytest.param(("é" * 127) + "a", id="255-byte-utf8"), |
| ], |
| ) |
| async def test_update_stream_accepts_name_boundaries( |
| self, iggy_client: IggyClient, unique_name, new_name: str |
| ): |
| """Test update_stream accepts names at the UTF-8 byte boundaries.""" |
| stream_name = unique_name() |
| await iggy_client.create_stream(stream_name) |
| |
| await iggy_client.update_stream(stream_id=stream_name, name=new_name) |
| renamed = await iggy_client.get_stream(new_name) |
| assert renamed is not None |
| assert renamed.name == new_name |
| |
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| "new_name", |
| [ |
| pytest.param("", id="empty"), |
| pytest.param("a" * 256, id="256-byte-ascii"), |
| pytest.param("é" * 128, id="256-byte-utf8"), |
| pytest.param(("😀" * 63) + "aaaa", id="256-byte-four-byte-utf8"), |
| ], |
| ) |
| async def test_update_stream_rejects_invalid_name_boundaries( |
| self, iggy_client: IggyClient, unique_name, new_name: str |
| ): |
| """Test update_stream rejects empty and 256-byte names.""" |
| stream_name = unique_name() |
| await iggy_client.create_stream(stream_name) |
| |
| with pytest.raises(RuntimeError): |
| await iggy_client.update_stream(stream_id=stream_name, name=new_name) |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_applies_repeated_updates( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test successive update_stream calls each take effect.""" |
| stream_name = unique_name() |
| first_rename = unique_name() |
| second_rename = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| await iggy_client.update_stream(stream_id=stream_name, name=first_rename) |
| after_first = await iggy_client.get_stream(first_rename) |
| assert after_first is not None |
| assert after_first.name == first_rename |
| assert await iggy_client.get_stream(stream_name) is None |
| |
| await iggy_client.update_stream(stream_id=first_rename, name=second_rename) |
| after_second = await iggy_client.get_stream(second_rename) |
| assert after_second is not None |
| assert after_second.name == second_rename |
| assert await iggy_client.get_stream(first_rename) is None |
| |
| @pytest.mark.asyncio |
| async def test_update_nonexistent_stream_fails( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test update_stream raises for a non-existent stream.""" |
| with pytest.raises(RuntimeError): |
| await iggy_client.update_stream(stream_id=unique_name(), name=unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_to_existing_name_fails_and_current_name_is_a_noop( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test update_stream rejects conflicts and preserves a self-rename.""" |
| first_stream = unique_name() |
| second_stream = unique_name() |
| |
| await iggy_client.create_stream(first_stream) |
| await iggy_client.create_stream(second_stream) |
| streams = await iggy_client.get_streams() |
| before = next(stream for stream in streams if stream.name == second_stream) |
| before_metadata = ( |
| before.id, |
| before.created_at, |
| before.name, |
| before.size, |
| before.messages_count, |
| before.topics_count, |
| ) |
| |
| with pytest.raises(RuntimeError): |
| await iggy_client.update_stream(stream_id=second_stream, name=first_stream) |
| |
| await iggy_client.update_stream(stream_id=second_stream, name=second_stream) |
| |
| streams = await iggy_client.get_streams() |
| after = next(stream for stream in streams if stream.id == before.id) |
| assert ( |
| after.id, |
| after.created_at, |
| after.name, |
| after.size, |
| after.messages_count, |
| after.topics_count, |
| ) == before_metadata |
| |
| @pytest.mark.asyncio |
| async def test_update_stream_requires_connection_and_auth(self, unique_name): |
| """Test update_stream fails both before connecting and before logging in.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| with pytest.raises(RuntimeError): |
| await client.update_stream(stream_id=unique_name(), name=unique_name()) |
| |
| await client.connect() |
| with pytest.raises(RuntimeError): |
| await client.update_stream(stream_id=unique_name(), name=unique_name()) |
| |
| |
| class TestDeleteStream: |
| """Test deleting streams via delete_stream.""" |
| |
| @pytest.mark.asyncio |
| async def test_delete_stream_removes_stream( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test delete_stream removes the stream so it no longer resolves.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| await iggy_client.delete_stream(stream_name) |
| |
| assert await iggy_client.get_stream(stream_name) is None |
| |
| @pytest.mark.asyncio |
| async def test_delete_stream_by_numeric_id( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test delete_stream accepts a numeric stream id.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| |
| await iggy_client.delete_stream(stream.id) |
| |
| assert await iggy_client.get_stream(stream_name) is None |
| |
| @pytest.mark.asyncio |
| async def test_delete_stream_leaves_other_streams( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test delete_stream removes only the targeted stream.""" |
| stream_to_delete = unique_name() |
| stream_to_keep = unique_name() |
| |
| await iggy_client.create_stream(stream_to_delete) |
| await iggy_client.create_stream(stream_to_keep) |
| |
| await iggy_client.delete_stream(stream_to_delete) |
| |
| assert await iggy_client.get_stream(stream_to_delete) is None |
| kept = await iggy_client.get_stream(stream_to_keep) |
| assert kept is not None |
| assert kept.name == stream_to_keep |
| |
| @pytest.mark.asyncio |
| async def test_delete_nonexistent_stream_fails( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test delete_stream raises for a non-existent stream.""" |
| with pytest.raises(RuntimeError): |
| await iggy_client.delete_stream(unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_delete_stream_twice_fails_second_time( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test deleting an already-deleted stream raises on the second call.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| await iggy_client.delete_stream(stream_name) |
| with pytest.raises(RuntimeError): |
| await iggy_client.delete_stream(stream_name) |
| |
| @pytest.mark.asyncio |
| async def test_delete_stream_requires_connection_and_auth(self, unique_name): |
| """Test delete_stream fails both before connecting and before logging in.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| with pytest.raises(RuntimeError): |
| await client.delete_stream(unique_name()) |
| |
| await client.connect() |
| with pytest.raises(RuntimeError): |
| await client.delete_stream(unique_name()) |
| |
| |
| class TestPurgeStream: |
| """Test purging stream messages via purge_stream.""" |
| |
| @pytest.mark.asyncio |
| async def test_purge_stream_clears_messages_but_keeps_stream( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test purge_stream empties the stream while leaving it in place.""" |
| stream_name = unique_name() |
| topic_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| await iggy_client.create_topic( |
| stream=stream_name, name=topic_name, partitions_count=1 |
| ) |
| |
| messages = [SendMessage(f"payload-{index}") for index in range(5)] |
| await iggy_client.send_messages(stream_name, topic_name, 0, messages) |
| |
| before = await iggy_client.get_stream(stream_name) |
| assert before is not None |
| assert before.messages_count == 5 |
| |
| await iggy_client.purge_stream(stream_name) |
| |
| after = await iggy_client.get_stream(stream_name) |
| # Purging clears messages only; the stream itself survives (purge is |
| # not delete) and keeps its identity and topics. |
| assert after is not None |
| assert after.messages_count == 0 |
| assert after.id == before.id |
| assert after.name == before.name |
| assert after.topics_count == before.topics_count |
| |
| @pytest.mark.asyncio |
| async def test_purge_empty_stream_succeeds( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test purge_stream is a no-op on a stream with no messages.""" |
| stream_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| |
| await iggy_client.purge_stream(stream_name) |
| |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| assert stream.messages_count == 0 |
| |
| @pytest.mark.asyncio |
| async def test_purge_stream_is_idempotent_when_called_repeatedly( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test purge_stream succeeds when called repeatedly on the same stream.""" |
| stream_name = unique_name() |
| topic_name = unique_name() |
| |
| await iggy_client.create_stream(stream_name) |
| await iggy_client.create_topic( |
| stream=stream_name, name=topic_name, partitions_count=1 |
| ) |
| |
| messages = [SendMessage(f"payload-{index}") for index in range(5)] |
| await iggy_client.send_messages(stream_name, topic_name, 0, messages) |
| |
| await iggy_client.purge_stream(stream_name) |
| await iggy_client.purge_stream(stream_name) |
| |
| stream = await iggy_client.get_stream(stream_name) |
| assert stream is not None |
| assert stream.messages_count == 0 |
| |
| @pytest.mark.asyncio |
| async def test_purge_nonexistent_stream_fails( |
| self, iggy_client: IggyClient, unique_name |
| ): |
| """Test purge_stream raises for a non-existent stream.""" |
| with pytest.raises(RuntimeError): |
| await iggy_client.purge_stream(unique_name()) |
| |
| @pytest.mark.asyncio |
| async def test_purge_stream_requires_connection_and_auth(self, unique_name): |
| """Test purge_stream fails both before connecting and before logging in.""" |
| host, port = get_server_config() |
| wait_for_server(host, port) |
| |
| client = IggyClient(f"{host}:{port}") |
| with pytest.raises(RuntimeError): |
| await client.purge_stream(unique_name()) |
| |
| await client.connect() |
| with pytest.raises(RuntimeError): |
| await client.purge_stream(unique_name()) |