From 37cff965372da283c17d456292e30189d784e680 Mon Sep 17 00:00:00 2001 From: TapTap Date: Thu, 10 Sep 2026 16:27:49 +0200 Subject: [PATCH] test(p6-stop): unit and integration tests for stop deadlines --- tests/integration/test_stop.py | 132 ++++++++++++++++++++++++++++++++ tests/runner.c | 2 + tests/test_stop.c | 136 +++++++++++++++++++++++++++++++++ tests/test_stop.h | 6 ++ 4 files changed, 276 insertions(+) create mode 100644 tests/integration/test_stop.py create mode 100644 tests/test_stop.c create mode 100644 tests/test_stop.h diff --git a/tests/integration/test_stop.py b/tests/integration/test_stop.py new file mode 100644 index 0000000..d10f03c --- /dev/null +++ b/tests/integration/test_stop.py @@ -0,0 +1,132 @@ +"""--stop-after / --stop-at deadline-stop integration tests. + +These cover the client-only sender stop conditions: --stop-after=MINS stops +after N elapsed minutes, --stop-at=HH:MM[:SS] or now+N[smhd] stops at an +absolute (or relative) wall-clock time. A reached deadline ends the transfer +elegantly at the next chunk/file boundary -- whatever was already transferred is +kept, the completion tail still runs, and the exit code is 0 (like rsync's +clean "stopped early" behavior). Malformed values are rejected up front. +""" +import os +import shutil +import time + +import pytest + +from common import ( + TEST_DATA_DIR, + run_client, + clean_dir, + get_dest_received_dir, + verify_transfer, +) + + +def _make(self_prefix): + source = os.path.join(TEST_DATA_DIR, f"stop_{self_prefix}_src") + dest = os.path.join(TEST_DATA_DIR, f"stop_{self_prefix}_dst") + clean_dir(source) + shutil.rmtree(dest, ignore_errors=True) + os.makedirs(dest) + return source, dest + + +def _received_files(root): + """All files under `root`, relative paths.""" + if not os.path.isdir(root): + return [] + return [ + os.path.relpath(os.path.join(dirpath, name), root) + for dirpath, _, names in os.walk(root) + for name in names + ] + + +def _seed_source(source): + """Create a handful of regular and nested files.""" + files = { + "small.txt": b"hello world\n", + "medium.txt": b"the quick brown fox jumps over the lazy dog\n" * 400, + "binary.bin": bytes(range(256)) * 100, + "nested/deep.txt": b"deeply nested file\n", + "nested/another.txt": b"another nested file\n" * 40, + } + for rel, content in files.items(): + path = os.path.join(source, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(content) + + +class TestStopAfter: + @pytest.mark.ci + def test_stop_after_within_window(self, shared_server): + """A --stop-after set well past the run's duration lets it finish fully.""" + source, dest = _make("within") + _seed_source(source) + result, _ = run_client(source, dest, flags=["--stop-after=60"], + port=shared_server.port) + assert result.returncode == 0, \ + f"--stop-after full run failed: {(result.stderr or result.stdout)[:400]}" + received = get_dest_received_dir(dest, source) + mismatches, missing = verify_transfer(source, received) + assert not mismatches and not missing, \ + f"full transfer mismatch: missing={missing} mismatches={mismatches}" + + @pytest.mark.ci + def test_stop_after_rejects_nonpositive(self, shared_server): + """0 and negative minutes are invalid (must be a positive integer).""" + source, dest = _make("reject") + _seed_source(source) + for bad in ("0", "-1"): + result, _ = run_client(source, dest, flags=[f"--stop-after={bad}"], + port=shared_server.port) + assert result.returncode != 0, f"--stop-after={bad} should be rejected" + + +class TestStopAt: + @pytest.mark.ci + def test_stop_at_past(self, shared_server): + """A --stop-at already in the past stops the transfer immediately but + cleanly (exit 0, nothing transferred).""" + source, dest = _make("past") + _seed_source(source) + now = time.localtime() + if now.tm_hour * 60 + now.tm_min >= 1: + past = time.localtime(time.time() - 120) + stop_value = f"{past.tm_hour:02d}:{past.tm_min:02d}" + else: + stop_value = "now+0s" # first minute of the day: use "immediately now" + result, _ = run_client(source, dest, flags=[f"--stop-at={stop_value}"], + port=shared_server.port) + assert result.returncode == 0, \ + f"--stop-at past run failed (rc {result.returncode}): " \ + f"{(result.stderr or result.stdout)[:400]}" + received = get_dest_received_dir(dest, source) + assert _received_files(received) == [], \ + f"expected nothing transferred, got {_received_files(received)}" + + @pytest.mark.ci + def test_stop_at_now_plus_stops_immediately(self, shared_server): + """now+0s resolves to the current instant, so the transfer stops at once.""" + source, dest = _make("nowplus") + _seed_source(source) + result, _ = run_client(source, dest, flags=["--stop-at=now+0s"], + port=shared_server.port) + assert result.returncode == 0, \ + f"--stop-at=now+0s should stop cleanly: " \ + f"{(result.stderr or result.stdout)[:400]}" + received = get_dest_received_dir(dest, source) + assert _received_files(received) == [], \ + f"expected nothing transferred, got {_received_files(received)}" + + @pytest.mark.ci + def test_stop_rejects_garbage(self, shared_server): + """Malformed --stop-at/--stop-after values are rejected up front.""" + source, dest = _make("garbage") + _seed_source(source) + for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=12", + "--stop-at=now+5x", "--stop-at=now-5s"): + result, _ = run_client(source, dest, flags=[flag], + port=shared_server.port) + assert result.returncode != 0, f"{flag} should be rejected" \ No newline at end of file diff --git a/tests/runner.c b/tests/runner.c index 5cf2d54..f376aa4 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -27,6 +27,7 @@ #include "test_server_cli.h" #include "test_shared_utils.h" #include "test_stress.h" +#include "test_stop.h" #include "test_transport_tcp.h" #include "test_transport_ssh.h" #include "test_transport_tls.h" @@ -67,6 +68,7 @@ int main() { RUN_TEST(test_log); RUN_TEST(test_robustness); RUN_TEST(test_stress); + RUN_TEST(test_stop); RUN_TEST(test_property); RUN_TEST(test_transport_tcp); RUN_TEST(test_transport_ssh); diff --git a/tests/test_stop.c b/tests/test_stop.c new file mode 100644 index 0000000..1e20239 --- /dev/null +++ b/tests/test_stop.c @@ -0,0 +1,136 @@ +#include "test_stop.h" +#include "stop_condition.h" +#include "test_utils.h" +#include +#include + +static void test_stop_after_parse_valid() { + int minutes = 0; + EXPECT_TRUE(stop_parse_after_minutes("5", &minutes)); + EXPECT_EQ_INT(minutes, 5); + EXPECT_TRUE(stop_parse_after_minutes("1", &minutes)); + EXPECT_EQ_INT(minutes, 1); + EXPECT_TRUE(stop_parse_after_minutes("1440", &minutes)); + EXPECT_EQ_INT(minutes, 1440); + EXPECT_TRUE(stop_parse_after_minutes("2147483647", &minutes)); + EXPECT_EQ_INT(minutes, INT_MAX); +} + +static void test_stop_after_parse_invalid() { + int minutes = 0; + EXPECT_FALSE(stop_parse_after_minutes("0", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("-1", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("abc", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("5x", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("1.5", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes(" 5 ", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes("2147483648", &minutes)); + EXPECT_FALSE(stop_parse_after_minutes(NULL, &minutes)); +} + +static void test_stop_at_parse_hhmm() { + time_t now = 1700000000; + time_t deadline = 0; + + EXPECT_TRUE(stop_parse_at_time("12:30", now, &deadline)); + struct tm t; + EXPECT_NOT_NULL(localtime_r(&deadline, &t)); + EXPECT_EQ_INT(t.tm_hour, 12); + EXPECT_EQ_INT(t.tm_min, 30); + EXPECT_EQ_INT(t.tm_sec, 0); + + EXPECT_TRUE(stop_parse_at_time("12:30:59", now, &deadline)); + EXPECT_NOT_NULL(localtime_r(&deadline, &t)); + EXPECT_EQ_INT(t.tm_hour, 12); + EXPECT_EQ_INT(t.tm_min, 30); + EXPECT_EQ_INT(t.tm_sec, 59); + + EXPECT_TRUE(stop_parse_at_time("00:00", now, &deadline)); + EXPECT_NOT_NULL(localtime_r(&deadline, &t)); + EXPECT_EQ_INT(t.tm_hour, 0); + EXPECT_EQ_INT(t.tm_min, 0); + EXPECT_EQ_INT(t.tm_sec, 0); +} + +static void test_stop_at_parse_now_plus() { + time_t now = 1700000000; + time_t deadline = 0; + + EXPECT_TRUE(stop_parse_at_time("now+90s", now, &deadline)); + EXPECT_EQ_INT(deadline, now + 90); + EXPECT_TRUE(stop_parse_at_time("now+5m", now, &deadline)); + EXPECT_EQ_INT(deadline, now + 300); + EXPECT_TRUE(stop_parse_at_time("now+2h", now, &deadline)); + EXPECT_EQ_INT(deadline, now + 7200); + EXPECT_TRUE(stop_parse_at_time("now+1d", now, &deadline)); + EXPECT_EQ_INT(deadline, now + 86400); + EXPECT_TRUE(stop_parse_at_time("now+0s", now, &deadline)); + EXPECT_EQ_INT(deadline, now); +} + +static void test_stop_at_parse_invalid() { + time_t now = 1700000000; + time_t deadline = 0; + EXPECT_FALSE(stop_parse_at_time("12", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12:3", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("1234", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12:30:5", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12:30:5x", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("24:00", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12:60", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12:30:61", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("12;00", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now+", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now+5", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now+5x", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now-5m", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("now+1w", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("abc", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time("", now, &deadline)); + EXPECT_FALSE(stop_parse_at_time(NULL, now, &deadline)); +} + +static void test_stop_deadline_latency() { + struct timespec now; + EXPECT_EQ_INT(clock_gettime(CLOCK_MONOTONIC, &now), 0); + + StopCondition future = stop_condition_make(true, 60, false, 0, now); + EXPECT_TRUE(future.has_monotonic); + EXPECT_EQ_INT(future.monotonic_deadline.tv_sec, now.tv_sec + 3600); + EXPECT_EQ_INT(future.monotonic_deadline.tv_nsec, now.tv_nsec); + EXPECT_FALSE(future.has_wall); + EXPECT_FALSE(stop_condition_reached(&future)); + + /* Move the 60-minute deadline into the past: the check now reports reached. */ + StopCondition past = stop_condition_make(true, 60, false, 0, now); + past.monotonic_deadline.tv_sec -= 7200; + EXPECT_TRUE(stop_condition_reached(&past)); + + StopCondition no_after = stop_condition_make(false, 0, false, 0, now); + EXPECT_FALSE(no_after.has_monotonic); + EXPECT_FALSE(no_after.has_wall); + EXPECT_FALSE(stop_condition_reached(&no_after)); + + /* --stop-at: a wall-clock deadline in the past/now is reached; one in the + future is not, and it stays independent of the monotonic half. */ + StopCondition wall_future = stop_condition_make(false, 0, true, time(NULL) + 3600, now); + EXPECT_TRUE(wall_future.has_wall); + EXPECT_FALSE(wall_future.has_monotonic); + EXPECT_FALSE(stop_condition_reached(&wall_future)); + + StopCondition wall_past = stop_condition_make(false, 0, true, time(NULL) - 1, now); + EXPECT_TRUE(stop_condition_reached(&wall_past)); + + EXPECT_FALSE(stop_condition_reached(NULL)); +} + +void test_stop(void) { + test_stop_after_parse_valid(); + test_stop_after_parse_invalid(); + test_stop_at_parse_hhmm(); + test_stop_at_parse_now_plus(); + test_stop_at_parse_invalid(); + test_stop_deadline_latency(); +} \ No newline at end of file diff --git a/tests/test_stop.h b/tests/test_stop.h new file mode 100644 index 0000000..3c5b85e --- /dev/null +++ b/tests/test_stop.h @@ -0,0 +1,6 @@ +#ifndef TEST_STOP_H +#define TEST_STOP_H + +void test_stop(void); + +#endif \ No newline at end of file