blob: f9429562205e304ed50a4577ec0482d9d5a7b922 [file]
# 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.
from datetime import timedelta
import pytest
from apache_iggy import HeaderValue, IggyClient, IggyExpiry, MaxTopicSize, SendMessage
from .utils import (
get_server_config,
wait_for_ping,
wait_for_server,
)
class TestCreateTopic:
"""Test topic creation via create_topic."""
@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_topic(
self,
iggy_client: IggyClient,
unique_name,
prefix: str,
min_bytes: int,
max_bytes: int,
):
"""Test topic creation and retrieval."""
stream_name = unique_name()
topic_name = unique_name(prefix, min_bytes=min_bytes, max_bytes=max_bytes)
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=2
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert topic.partitions_count == 2
assert topic.created_at > 0
assert topic.size == 0
assert len(topic.partitions) == 2
assert all(partition.messages_count == 0 for partition in topic.partitions)
stream = await iggy_client.get_stream(stream_name)
assert stream is not None
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_topic_invalid_names(
self,
iggy_client: IggyClient,
unique_name,
prefix: str,
min_bytes: int,
max_bytes: int,
):
"""Test create_topic enforces byte-length validation."""
stream_name = unique_name()
topic_name = unique_name(prefix, min_bytes=min_bytes, max_bytes=max_bytes)
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError):
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
@pytest.mark.asyncio
async def test_create_and_get_topic_with_numeric_stream_id(
self, iggy_client: IggyClient, unique_name
):
"""Test topic APIs accept a numeric stream id for create and get operations."""
stream_name = unique_name()
topic_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.create_topic(
stream=stream.id, name=topic_name, partitions_count=2
)
topic_by_name = await iggy_client.get_topic(stream.id, topic_name)
assert topic_by_name is not None
assert topic_by_name.name == topic_name
assert topic_by_name.partitions_count == 2
topic_by_id = await iggy_client.get_topic(stream.id, topic_by_name.id)
assert topic_by_id is not None
assert topic_by_id.id == topic_by_name.id
assert topic_by_id.name == topic_by_name.name
@pytest.mark.asyncio
async def test_duplicate_topic_creation(self, iggy_client: IggyClient, unique_name):
"""Test that creating duplicate topics raises appropriate errors."""
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
)
with pytest.raises(RuntimeError) as exc_info:
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
assert "already exists" in str(exc_info.value)
@pytest.mark.asyncio
async def test_topic_names_can_repeat_across_different_streams(
self, iggy_client: IggyClient, unique_name
):
"""Test the same topic name can be created in different streams."""
first_stream_name = unique_name()
second_stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(first_stream_name)
await iggy_client.create_stream(second_stream_name)
await iggy_client.create_topic(
stream=first_stream_name, name=topic_name, partitions_count=1
)
await iggy_client.create_topic(
stream=second_stream_name, name=topic_name, partitions_count=1
)
first_topic = await iggy_client.get_topic(first_stream_name, topic_name)
second_topic = await iggy_client.get_topic(second_stream_name, topic_name)
assert first_topic is not None
assert second_topic is not None
assert first_topic.name == topic_name
assert second_topic.name == topic_name
assert first_topic.partitions_count == 1
assert second_topic.partitions_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("compression_algorithm", ["gzip", "Gzip", "none", "None"])
async def test_create_topic_with_valid_compression_algorithm(
self, iggy_client: IggyClient, unique_name, compression_algorithm: str
):
"""Test create_topic accepts a supported compression algorithm value."""
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,
compression_algorithm=compression_algorithm,
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert topic.partitions_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"compression_algorithm", ["brotli", "deflate", "gzipp", "", " gzip "]
)
async def test_create_topic_invalid_compression_algorithm(
self, iggy_client: IggyClient, unique_name, compression_algorithm: str
):
"""Test create_topic rejects unsupported compression algorithm values."""
stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError, match="Unknown compression type"):
await iggy_client.create_topic(
stream=stream_name,
name=topic_name,
partitions_count=1,
compression_algorithm=compression_algorithm,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"message_expiry",
[
IggyExpiry.ExpireDuration(timedelta(microseconds=1)),
IggyExpiry.ExpireDuration(timedelta(seconds=1)),
IggyExpiry.ExpireDuration(timedelta(minutes=10)),
IggyExpiry.ExpireDuration(timedelta(days=1, seconds=2, microseconds=3)),
# days * 86_400 overflows i32 (max ~24,855 days); regression test
# for widening the days-to-seconds conversion to i64.
IggyExpiry.ExpireDuration(timedelta(days=30_000)),
],
)
async def test_create_topic_with_message_expiry(
self,
iggy_client: IggyClient,
unique_name,
message_expiry: IggyExpiry.ExpireDuration,
):
"""Test create_topic accepts an explicit message expiry."""
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,
message_expiry=message_expiry,
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration)
assert topic.message_expiry.duration == message_expiry.duration
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_duration",
[
timedelta(seconds=-1),
# 0 is the wire sentinel reserved for IggyExpiry.ServerDefault();
# matches MaxTopicSize.Custom(0) rejecting its own sentinel.
timedelta(0),
# u64::MAX microseconds is the wire sentinel reserved for
# IggyExpiry.NeverExpire(); matches MaxTopicSize.Custom(u64::MAX).
timedelta(microseconds=2**64 - 1),
],
)
async def test_create_topic_rejects_invalid_message_expiry_duration(
self, iggy_client: IggyClient, unique_name, invalid_duration: timedelta
):
"""Test create_topic rejects an ExpireDuration at a reserved boundary."""
stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(ValueError):
await iggy_client.create_topic(
stream=stream_name,
name=topic_name,
partitions_count=1,
message_expiry=IggyExpiry.ExpireDuration(invalid_duration),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_message_expiry", [1, "1s", object(), timedelta(seconds=1)]
)
async def test_create_topic_invalid_message_expiry(
self, iggy_client: IggyClient, unique_name, invalid_message_expiry
):
"""Test create_topic rejects non-IggyExpiry message_expiry values."""
stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(TypeError):
await iggy_client.create_topic(
stream=stream_name,
name=topic_name,
partitions_count=1,
message_expiry=invalid_message_expiry,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("max_topic_size", "expected_kind"),
[
(MaxTopicSize.ServerDefault(), "unlimited"), # resolved by create_topic
(MaxTopicSize.Unlimited(), "unlimited"),
(MaxTopicSize.Custom(2_000_000_000), "custom"),
],
)
async def test_create_topic_with_valid_max_topic_size(
self,
iggy_client: IggyClient,
unique_name,
max_topic_size: MaxTopicSize,
expected_kind: str,
):
"""Test create_topic accepts supported maximum topic size values."""
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,
max_topic_size=max_topic_size,
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
if expected_kind == "unlimited":
assert isinstance(topic.max_topic_size, MaxTopicSize.Unlimited)
else:
assert isinstance(topic.max_topic_size, MaxTopicSize.Custom)
assert isinstance(max_topic_size, MaxTopicSize.Custom)
assert topic.max_topic_size.bytes == max_topic_size.bytes
@pytest.mark.asyncio
@pytest.mark.parametrize(
("max_topic_size_bytes", "expected_exception"),
[
(4563, RuntimeError),
(-1, OverflowError),
(2e64, TypeError),
(0, ValueError),
(2**64 - 1, ValueError), # u64::MAX is reserved for Unlimited
],
)
async def test_create_topic_invalid_max_topic_size(
self,
iggy_client: IggyClient,
unique_name,
max_topic_size_bytes,
expected_exception,
):
"""Test create_topic rejects invalid maximum topic size values."""
stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(expected_exception):
await iggy_client.create_topic(
stream=stream_name,
name=topic_name,
partitions_count=1,
max_topic_size=MaxTopicSize.Custom(max_topic_size_bytes),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("partitions_count", [1001, 10000])
async def test_create_topic_invalid_partitions_count(
self, iggy_client: IggyClient, unique_name, partitions_count: int
):
"""Test create_topic rejects partition counts above the supported limit."""
stream_name = unique_name()
topic_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError) as exc_info:
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=partitions_count
)
assert "Too many partitions" in str(exc_info.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("partitions_count", [0, 1, 1000])
async def test_create_topic_with_valid_partitions_count(
self, iggy_client: IggyClient, unique_name, partitions_count: int
):
"""Test create_topic accepts the maximum supported partitions count."""
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=partitions_count
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert topic.partitions_count == partitions_count
@pytest.mark.asyncio
async def test_stream_topics_count_increases_after_multiple_topic_creations(
self, iggy_client: IggyClient, unique_name
):
"""Test a stream reports additional topics after multiple creations."""
stream_name = unique_name()
first_topic_name = unique_name()
second_topic_name = unique_name()
await iggy_client.create_stream(stream_name)
stream_before = await iggy_client.get_stream(stream_name)
assert stream_before is not None
assert stream_before.topics_count == 0
await iggy_client.create_topic(
stream=stream_name, name=first_topic_name, partitions_count=1
)
await iggy_client.create_topic(
stream=stream_name, name=second_topic_name, partitions_count=1
)
stream_after = await iggy_client.get_stream(stream_name)
assert stream_after is not None
assert stream_after.topics_count == 2
@pytest.mark.asyncio
async def test_create_topic_then_reconnect_then_get_topic(
self, iggy_client: IggyClient, unique_name
):
"""Test a topic remains retrievable after reconnecting with a fresh client."""
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
)
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")
topic = await client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert topic.partitions_count == 1
@pytest.mark.asyncio
async def test_create_topic_in_nonexistent_stream(
self, iggy_client: IggyClient, unique_name
):
"""Test creating a topic in a non-existent stream."""
nonexistent_stream = unique_name()
topic_name = unique_name()
with pytest.raises(RuntimeError):
await iggy_client.create_topic(
stream=nonexistent_stream, name=topic_name, partitions_count=1
)
@pytest.mark.asyncio
async def test_create_topic_requires_connection_and_auth(self, unique_name):
"""Test create_topic 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.create_topic(
stream=unique_name(), name=unique_name(), partitions_count=1
)
await client.connect()
with pytest.raises(RuntimeError):
await client.create_topic(
stream=unique_name(), name=unique_name(), partitions_count=1
)
class TestGetTopic:
"""Test topic retrieval via get_topic."""
@pytest.mark.asyncio
async def test_get_topic_by_name_and_id(self, iggy_client: IggyClient, unique_name):
"""Test repeated topic lookup works by both name and numeric id."""
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
)
topic_by_name = await iggy_client.get_topic(stream_name, topic_name)
assert topic_by_name is not None
topic_by_name_again = await iggy_client.get_topic(stream_name, topic_name)
assert topic_by_name_again is not None
assert topic_by_name_again.id == topic_by_name.id
assert topic_by_name_again.name == topic_by_name.name
topic_by_id = await iggy_client.get_topic(stream_name, topic_by_name.id)
assert topic_by_id is not None
assert topic_by_id.id == topic_by_name.id
assert topic_by_id.name == topic_by_name.name
@pytest.mark.asyncio
async def test_get_topic_partitions(self, iggy_client: IggyClient, unique_name):
"""Test TopicDetails.partitions returns one Partition per partition."""
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=3
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert len(topic.partitions) == 3
assert [partition.id for partition in topic.partitions] == [0, 1, 2]
for partition in topic.partitions:
assert partition.created_at > 0
assert partition.segments_count == 1
assert partition.current_offset == 0
assert partition.size == 0
assert partition.messages_count == 0
@pytest.mark.asyncio
async def test_get_nonexistent_topic(self, iggy_client: IggyClient, unique_name):
"""Test getting a non-existent topic by name or numeric id."""
stream_name = unique_name()
nonexistent_topic_name = unique_name()
await iggy_client.create_stream(stream_name)
topic_by_name = await iggy_client.get_topic(stream_name, nonexistent_topic_name)
assert topic_by_name is None
topic_by_id = await iggy_client.get_topic(stream_name, 999999)
assert topic_by_id is None
@pytest.mark.asyncio
async def test_get_topic_in_nonexistent_stream(
self, iggy_client: IggyClient, unique_name
):
"""Test getting a topic from a non-existent stream returns no topic."""
nonexistent_stream = unique_name()
topic_name = unique_name()
topic = await iggy_client.get_topic(nonexistent_stream, topic_name)
assert topic is None
@pytest.mark.asyncio
async def test_get_topic_requires_connection_and_auth(self, unique_name):
"""Test get_topic 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_topic(unique_name(), unique_name())
await client.connect()
with pytest.raises(RuntimeError):
await client.get_topic(unique_name(), unique_name())
class TestGetTopics:
"""Test listing topics in a stream via get_topics."""
@pytest.mark.asyncio
async def test_get_topics_in_empty_stream_returns_empty_list(
self, iggy_client: IggyClient, unique_name
):
"""Test get_topics returns an empty list for a stream with no topics."""
stream_name = unique_name()
await iggy_client.create_stream(stream_name)
topics = await iggy_client.get_topics(stream_name)
assert topics == []
@pytest.mark.asyncio
@pytest.mark.parametrize("topic_count", [1, 3, 5])
async def test_get_topics_returns_all_created_topics(
self, iggy_client: IggyClient, unique_name, topic_count: int
):
"""Test get_topics returns every created topic, ordered by id ascending."""
stream_name = unique_name()
# Reverse-alphabetical names so that id-ascending (creation) order
# and name order disagree, proving the list isn't accidentally
# name-sorted.
topic_names = [unique_name() for _ in range(topic_count, 0, -1)]
await iggy_client.create_stream(stream_name)
for name in topic_names:
await iggy_client.create_topic(
stream=stream_name, name=name, partitions_count=1
)
topics = await iggy_client.get_topics(stream_name)
assert len(topics) == len(topic_names)
assert [topic.name for topic in topics] == topic_names
assert [topic.id for topic in topics] == sorted(topic.id for topic in topics)
# Only assert fields supplied at creation or deterministically set by
# the server: each topic keeps its partition count and a fresh topic
# holds no messages. The numeric id is server-assigned and not checked.
assert all(topic.partitions_count == 1 for topic in topics)
assert all(topic.messages_count == 0 for topic in topics)
@pytest.mark.asyncio
async def test_get_topics_returns_same_result_when_called_repeatedly(
self, iggy_client: IggyClient, unique_name
):
"""Test repeated get_topics calls return an identically ordered view."""
stream_name = unique_name()
topic_names = [unique_name() for _ in range(3)]
await iggy_client.create_stream(stream_name)
for name in topic_names:
await iggy_client.create_topic(
stream=stream_name, name=name, partitions_count=1
)
first = await iggy_client.get_topics(stream_name)
second = await iggy_client.get_topics(stream_name)
assert [topic.name for topic in first] == topic_names
assert [topic.id for topic in first] == [topic.id for topic in second]
assert [topic.name for topic in first] == [topic.name for topic in second]
@pytest.mark.asyncio
async def test_get_topics_accepts_numeric_stream_id(
self, iggy_client: IggyClient, unique_name
):
"""Test get_topics works when the stream is referenced by numeric id."""
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
)
stream = await iggy_client.get_stream(stream_name)
assert stream is not None
topics_by_name = await iggy_client.get_topics(stream_name)
topics_by_id = await iggy_client.get_topics(stream.id)
assert len(topics_by_name) == 1
assert len(topics_by_id) == 1
assert topics_by_id[0].name == topic_name
@pytest.mark.asyncio
async def test_get_topics_in_nonexistent_stream_returns_empty(
self, iggy_client: IggyClient, unique_name
):
"""Test get_topics returns an empty list for a non-existent stream."""
topics = await iggy_client.get_topics(unique_name())
assert topics == []
@pytest.mark.asyncio
async def test_get_topics_requires_connection_and_auth(self, unique_name):
"""Test get_topics 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_topics(unique_name())
await client.connect()
with pytest.raises(RuntimeError):
await client.get_topics(unique_name())
class TestUpdateTopic:
"""Test updating topics via update_topic."""
@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_update_topic_invalid_names(
self,
iggy_client: IggyClient,
unique_name,
prefix: str,
min_bytes: int,
max_bytes: int,
):
"""Test update_topic enforces byte-length validation."""
stream_name = unique_name()
topic_name = unique_name()
invalid_name = unique_name(prefix, min_bytes=min_bytes, max_bytes=max_bytes)
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
with pytest.raises(RuntimeError):
await iggy_client.update_topic(
stream_id=stream_name, topic_id=topic_name, name=invalid_name
)
@pytest.mark.asyncio
async def test_update_topic_renames_topic(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic renames a topic; old name no longer resolves."""
stream_name = unique_name()
topic_name = unique_name()
new_name = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
await iggy_client.update_topic(
stream_id=stream_name, topic_id=topic_name, name=new_name
)
renamed = await iggy_client.get_topic(stream_name, new_name)
assert renamed is not None
assert renamed.name == new_name
old = await iggy_client.get_topic(stream_name, topic_name)
assert old is None
@pytest.mark.asyncio
async def test_update_topic_preserves_id(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic keeps the same numeric id after a rename."""
stream_name = unique_name()
topic_name = unique_name()
new_name = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
before = await iggy_client.get_topic(stream_name, topic_name)
assert before is not None
await iggy_client.update_topic(
stream_id=stream_name, topic_id=topic_name, name=new_name
)
after = await iggy_client.get_topic(stream_name, new_name)
assert after is not None
assert after.id == before.id
@pytest.mark.asyncio
async def test_update_topic_by_numeric_id(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic accepts a numeric topic id."""
stream_name = unique_name()
topic_name = unique_name()
new_name = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
await iggy_client.update_topic(
stream_id=stream_name, topic_id=topic.id, name=new_name
)
renamed = await iggy_client.get_topic(stream_name, new_name)
assert renamed is not None
assert renamed.name == new_name
@pytest.mark.asyncio
@pytest.mark.parametrize("compression_algorithm", ["gzip", "none"])
async def test_update_topic_with_valid_compression_algorithm(
self, iggy_client: IggyClient, unique_name, compression_algorithm: str
):
"""Test update_topic accepts a supported compression algorithm."""
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
)
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
compression_algorithm=compression_algorithm,
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert topic.compression_algorithm == compression_algorithm
@pytest.mark.asyncio
@pytest.mark.parametrize("compression_algorithm", ["brotli", "gzipp", ""])
async def test_update_topic_invalid_compression_algorithm(
self, iggy_client: IggyClient, unique_name, compression_algorithm: str
):
"""Test update_topic rejects unsupported compression algorithm values."""
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
)
with pytest.raises(RuntimeError, match="Unknown compression type"):
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
compression_algorithm=compression_algorithm,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_message_expiry", [1, "1s", object(), timedelta(seconds=1)]
)
async def test_update_topic_invalid_message_expiry(
self, iggy_client: IggyClient, unique_name, invalid_message_expiry
):
"""Test update_topic rejects non-IggyExpiry message_expiry values."""
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
)
with pytest.raises(TypeError):
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
message_expiry=invalid_message_expiry,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("max_topic_size_bytes", "expected_exception"),
[
(-1, OverflowError),
(2e64, TypeError),
(0, ValueError),
(2**64 - 1, ValueError), # u64::MAX is reserved for Unlimited
],
)
async def test_update_topic_invalid_max_topic_size(
self,
iggy_client: IggyClient,
unique_name,
max_topic_size_bytes,
expected_exception,
):
"""Test update_topic rejects invalid maximum topic size values."""
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
)
with pytest.raises(expected_exception):
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
max_topic_size=MaxTopicSize.Custom(max_topic_size_bytes),
)
@pytest.mark.asyncio
async def test_update_topic_with_message_expiry(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic accepts an explicit message expiry."""
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
)
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
message_expiry=IggyExpiry.ExpireDuration(timedelta(minutes=10)),
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration)
assert topic.message_expiry.duration == timedelta(minutes=10)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_duration",
[
timedelta(seconds=-1),
# 0 is the wire sentinel reserved for IggyExpiry.ServerDefault();
# matches MaxTopicSize.Custom(0) rejecting its own sentinel.
timedelta(0),
# u64::MAX microseconds is the wire sentinel reserved for
# IggyExpiry.NeverExpire(); matches MaxTopicSize.Custom(u64::MAX).
timedelta(microseconds=2**64 - 1),
],
)
async def test_update_topic_rejects_invalid_message_expiry_duration(
self, iggy_client: IggyClient, unique_name, invalid_duration: timedelta
):
"""Test update_topic rejects an ExpireDuration at a reserved boundary."""
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
)
with pytest.raises(ValueError):
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
message_expiry=IggyExpiry.ExpireDuration(invalid_duration),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("max_topic_size", "expected_kind"),
[
(MaxTopicSize.ServerDefault(), "server_default"),
(MaxTopicSize.Custom(2_000_000_000), "custom"),
(MaxTopicSize.Unlimited(), "unlimited"),
],
)
async def test_update_topic_with_valid_max_topic_size(
self,
iggy_client: IggyClient,
unique_name,
max_topic_size: MaxTopicSize,
expected_kind: str,
):
"""Test update_topic accepts supported maximum topic size values."""
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
)
created = await iggy_client.get_topic(stream_name, topic_name)
assert created is not None
resolved_at_creation = created.max_topic_size
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
max_topic_size=max_topic_size,
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.name == topic_name
if expected_kind == "server_default":
# Every setting rides the options block and 0 is its "resolve the
# default" sentinel, so a ServerDefault update carries no key at all
# and the topic keeps the value admission resolved when it was
# created. Resetting a setting back to the node default is
# deliberately not expressible.
assert isinstance(topic.max_topic_size, type(resolved_at_creation))
assert not isinstance(topic.max_topic_size, MaxTopicSize.ServerDefault)
elif expected_kind == "unlimited":
assert isinstance(topic.max_topic_size, MaxTopicSize.Unlimited)
else:
assert isinstance(topic.max_topic_size, MaxTopicSize.Custom)
assert isinstance(max_topic_size, MaxTopicSize.Custom)
assert topic.max_topic_size.bytes == max_topic_size.bytes
@pytest.mark.asyncio
async def test_update_topic_applies_repeated_updates(
self, iggy_client: IggyClient, unique_name
):
"""Test successive update_topic calls each take effect."""
stream_name = unique_name()
topic_name = unique_name()
first_rename = unique_name()
second_rename = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_name, partitions_count=1
)
await iggy_client.update_topic(
stream_id=stream_name, topic_id=topic_name, name=first_rename
)
after_first = await iggy_client.get_topic(stream_name, first_rename)
assert after_first is not None
assert after_first.name == first_rename
assert await iggy_client.get_topic(stream_name, topic_name) is None
await iggy_client.update_topic(
stream_id=stream_name, topic_id=first_rename, name=second_rename
)
after_second = await iggy_client.get_topic(stream_name, second_rename)
assert after_second is not None
assert after_second.name == second_rename
assert await iggy_client.get_topic(stream_name, first_rename) is None
@pytest.mark.asyncio
async def test_update_nonexistent_topic_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic raises for a non-existent topic."""
stream_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError):
await iggy_client.update_topic(
stream_id=stream_name, topic_id=unique_name(), name=unique_name()
)
@pytest.mark.asyncio
async def test_update_topic_in_nonexistent_stream_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic raises when the stream does not exist."""
with pytest.raises(RuntimeError):
await iggy_client.update_topic(
stream_id=unique_name(), topic_id=unique_name(), name=unique_name()
)
@pytest.mark.asyncio
async def test_update_topic_to_existing_name_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test update_topic rejects renaming a topic to a name already in use."""
stream_name = unique_name()
first_topic = unique_name()
second_topic = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=first_topic, partitions_count=1
)
await iggy_client.create_topic(
stream=stream_name, name=second_topic, partitions_count=1
)
with pytest.raises(RuntimeError):
await iggy_client.update_topic(
stream_id=stream_name, topic_id=second_topic, name=first_topic
)
@pytest.mark.asyncio
async def test_update_topic_requires_connection_and_auth(self, unique_name):
"""Test update_topic 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_topic(
stream_id=unique_name(), topic_id=unique_name(), name=unique_name()
)
await client.connect()
with pytest.raises(RuntimeError):
await client.update_topic(
stream_id=unique_name(), topic_id=unique_name(), name=unique_name()
)
class TestDeleteTopic:
"""Test deleting topics via delete_topic."""
@pytest.mark.asyncio
async def test_delete_topic_removes_topic(
self, iggy_client: IggyClient, unique_name
):
"""Test delete_topic removes the topic and drops the stream count."""
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
)
await iggy_client.delete_topic(stream_name, topic_name)
assert await iggy_client.get_topic(stream_name, topic_name) is None
assert await iggy_client.get_topics(stream_name) == []
stream = await iggy_client.get_stream(stream_name)
assert stream is not None
assert stream.topics_count == 0
@pytest.mark.asyncio
async def test_delete_topic_by_numeric_id(
self, iggy_client: IggyClient, unique_name
):
"""Test delete_topic accepts a numeric topic id."""
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
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
await iggy_client.delete_topic(stream_name, topic.id)
assert await iggy_client.get_topic(stream_name, topic_name) is None
@pytest.mark.asyncio
async def test_delete_topic_leaves_other_topics(
self, iggy_client: IggyClient, unique_name
):
"""Test delete_topic removes only the targeted topic."""
stream_name = unique_name()
topic_to_delete = unique_name()
topic_to_keep = unique_name()
await iggy_client.create_stream(stream_name)
await iggy_client.create_topic(
stream=stream_name, name=topic_to_delete, partitions_count=1
)
await iggy_client.create_topic(
stream=stream_name, name=topic_to_keep, partitions_count=1
)
await iggy_client.delete_topic(stream_name, topic_to_delete)
remaining = await iggy_client.get_topics(stream_name)
assert len(remaining) == 1
assert remaining[0].name == topic_to_keep
@pytest.mark.asyncio
async def test_delete_nonexistent_topic_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test delete_topic raises for a non-existent topic."""
stream_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError):
await iggy_client.delete_topic(stream_name, unique_name())
@pytest.mark.asyncio
async def test_delete_topic_twice_fails_second_time(
self, iggy_client: IggyClient, unique_name
):
"""Test deleting an already-deleted topic raises on the second call."""
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
)
await iggy_client.delete_topic(stream_name, topic_name)
with pytest.raises(RuntimeError):
await iggy_client.delete_topic(stream_name, topic_name)
@pytest.mark.asyncio
async def test_delete_topic_requires_connection_and_auth(self, unique_name):
"""Test delete_topic 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_topic(unique_name(), unique_name())
await client.connect()
with pytest.raises(RuntimeError):
await client.delete_topic(unique_name(), unique_name())
class TestPurgeTopic:
"""Test purging topic messages via purge_topic."""
@pytest.mark.asyncio
async def test_purge_topic_clears_messages_but_keeps_topic(
self, iggy_client: IggyClient, unique_name
):
"""Test purge_topic empties the topic 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_topic(stream_name, topic_name)
assert before is not None
assert before.messages_count == 5
await iggy_client.purge_topic(stream_name, topic_name)
after = await iggy_client.get_topic(stream_name, topic_name)
assert after is not None
assert after.messages_count == 0
assert after.size == 0
# Purging clears messages and size only; topic config is unchanged.
assert after.id == before.id
assert after.name == before.name
assert after.created_at == before.created_at
assert after.partitions_count == before.partitions_count
assert after.compression_algorithm == before.compression_algorithm
assert isinstance(before.message_expiry, IggyExpiry.NeverExpire)
assert isinstance(after.message_expiry, IggyExpiry.NeverExpire)
assert isinstance(before.max_topic_size, MaxTopicSize.Unlimited)
assert isinstance(after.max_topic_size, MaxTopicSize.Unlimited)
@pytest.mark.asyncio
async def test_purge_empty_topic_succeeds(
self, iggy_client: IggyClient, unique_name
):
"""Test purge_topic is a no-op on a topic with no messages."""
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
)
await iggy_client.purge_topic(stream_name, topic_name)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.messages_count == 0
@pytest.mark.asyncio
async def test_purge_topic_is_idempotent_when_called_repeatedly(
self, iggy_client: IggyClient, unique_name
):
"""Test purge_topic succeeds when called repeatedly on the same topic."""
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_topic(stream_name, topic_name)
await iggy_client.purge_topic(stream_name, topic_name)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.messages_count == 0
assert topic.size == 0
@pytest.mark.asyncio
async def test_purge_nonexistent_topic_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test purge_topic raises for a non-existent topic in an existing stream."""
stream_name = unique_name()
await iggy_client.create_stream(stream_name)
with pytest.raises(RuntimeError):
await iggy_client.purge_topic(stream_name, unique_name())
@pytest.mark.asyncio
async def test_purge_topic_in_nonexistent_stream_fails(
self, iggy_client: IggyClient, unique_name
):
"""Test purge_topic raises when the stream does not exist."""
with pytest.raises(RuntimeError):
await iggy_client.purge_topic(unique_name(), unique_name())
@pytest.mark.asyncio
async def test_purge_topic_requires_connection_and_auth(self, unique_name):
"""Test purge_topic 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_topic(unique_name(), unique_name())
await client.connect()
with pytest.raises(RuntimeError):
await client.purge_topic(unique_name(), unique_name())
class TestTopicOptions:
"""Tests for the option catalog and the options a topic reports."""
@pytest.mark.asyncio
async def test_topic_options_round_trip(self, iggy_client: IggyClient, unique_name):
"""Options a client sets come back readable, split by provenance."""
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,
options={"enforce_fsync": "true", "segment_size": "128 MiB"},
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
# Options come back through the same typed dict message user headers
# use, so the scalar helper reads them the same way.
explicit = topic.options.to_scalar_dict()
assert explicit["enforce_fsync"] is True
assert explicit["segment_size"] == 128 * 1024 * 1024
# Keys the client left alone are resolved by admission and reported
# separately, so an operator can tell chosen from defaulted.
derived = topic.derived_options.to_scalar_dict()
assert "max_topic_size" in derived
assert "enforce_fsync" not in derived
topics = await iggy_client.get_topics(stream_name)
listed = next(entry for entry in topics if entry.name == topic_name)
assert listed.options.to_scalar_dict()["enforce_fsync"] is True
@pytest.mark.asyncio
async def test_update_topic_options_reach_the_server(
self, iggy_client: IggyClient, unique_name
):
"""Options passed to update_topic are applied, and gated by key."""
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
)
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
options={"compression_algorithm": "gzip"},
)
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.compression_algorithm == "gzip"
# A create-time key is refused by name, so nothing re-pushes it to the
# partitions of a live topic.
with pytest.raises(RuntimeError):
await iggy_client.update_topic(
stream_id=stream_name,
topic_id=topic_name,
name=topic_name,
options={"segment_size": "2 MiB"},
)
@pytest.mark.asyncio
async def test_describe_options_lists_the_topic_catalog(
self, iggy_client: IggyClient
):
"""The catalog is what tells a client which keys create accepts."""
specs = await iggy_client.describe_options("topic")
by_key = {spec.key: spec for spec in specs}
assert "segment_size" in by_key
assert "enforce_fsync" in by_key
segment_size = by_key["segment_size"]
assert segment_size.kind == "uint64"
# The default is the same HeaderValue type message headers carry, so it
# arrives as the variant matching the key's kind.
default = segment_size.default_value
assert isinstance(default, HeaderValue.UnsignedInt64)
assert default.value == 1024 * 1024 * 1024
assert segment_size.description
# Streams and users have no catalog keys yet.
assert await iggy_client.describe_options("stream") == []
assert await iggy_client.describe_options("user") == []
@pytest.mark.asyncio
async def test_describe_options_rejects_an_unknown_scope(
self, iggy_client: IggyClient
):
"""Test describe_options raises ValueError for an unknown scope."""
with pytest.raises(ValueError):
await iggy_client.describe_options("partition")