diff options
Diffstat (limited to 'tests/integration')
| -rw-r--r-- | tests/integration/harness.py | 78 | ||||
| -rw-r--r-- | tests/integration/test_accesslist.py | 16 | ||||
| -rw-r--r-- | tests/integration/test_fullscrape.py | 32 | ||||
| -rw-r--r-- | tests/integration/test_keepalive.py | 111 | ||||
| -rw-r--r-- | tests/integration/test_stats.py | 49 | ||||
| -rw-r--r-- | tests/integration/test_udp.py | 15 |
6 files changed, 279 insertions, 22 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py index 7e75574..03a0561 100644 --- a/tests/integration/harness.py +++ b/tests/integration/harness.py | |||
| @@ -197,6 +197,10 @@ class Tracker: | |||
| 197 | assert action == 2, "udp scrape answered action %d" % action | 197 | assert action == 2, "udp scrape answered action %d" % action |
| 198 | return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))] | 198 | return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))] |
| 199 | 199 | ||
| 200 | def connect(self): | ||
| 201 | """A connection the caller drives and closes.""" | ||
| 202 | return Connection(self.port) | ||
| 203 | |||
| 200 | def udp_raw(self, payload, timeout=1.0): | 204 | def udp_raw(self, payload, timeout=1.0): |
| 201 | """Sends a packet exactly as given. Returns the answer, or None if the | 205 | """Sends a packet exactly as given. Returns the answer, or None if the |
| 202 | tracker chose to say nothing, which is how it treats a broken one.""" | 206 | tracker chose to say nothing, which is how it treats a broken one.""" |
| @@ -212,6 +216,73 @@ class Tracker: | |||
| 212 | sock.close() | 216 | sock.close() |
| 213 | 217 | ||
| 214 | 218 | ||
| 219 | class Connection: | ||
| 220 | """One HTTP connection the caller keeps open, which urllib will not do. | ||
| 221 | |||
| 222 | Reads exactly one reply at a time, so a test can tell "the tracker | ||
| 223 | answered and kept the socket" from "the tracker answered and hung up".""" | ||
| 224 | |||
| 225 | def __init__(self, port, timeout=5): | ||
| 226 | self.sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) | ||
| 227 | self.sock.settimeout(timeout) | ||
| 228 | self.buffer = b"" | ||
| 229 | |||
| 230 | def send(self, path, connection="Keep-Alive", version="1.1"): | ||
| 231 | request = "GET %s HTTP/%s\r\nHost: tracker\r\n" % (path, version) | ||
| 232 | if connection is not None: | ||
| 233 | request += "Connection: %s\r\n" % connection | ||
| 234 | self.sock.sendall((request + "\r\n").encode()) | ||
| 235 | |||
| 236 | def _fill(self): | ||
| 237 | chunk = self.sock.recv(65536) | ||
| 238 | if not chunk: | ||
| 239 | raise EOFError("the tracker closed the connection") | ||
| 240 | self.buffer += chunk | ||
| 241 | |||
| 242 | def reply(self): | ||
| 243 | """The one complete reply at the front of the stream, headers and body.""" | ||
| 244 | while b"\r\n\r\n" not in self.buffer: | ||
| 245 | self._fill() | ||
| 246 | head, rest = self.buffer.split(b"\r\n\r\n", 1) | ||
| 247 | headers = {} | ||
| 248 | for line in head.split(b"\r\n")[1:]: | ||
| 249 | name, _, value = line.partition(b":") | ||
| 250 | headers[name.strip().lower()] = value.strip() | ||
| 251 | |||
| 252 | if headers.get(b"transfer-encoding") == b"chunked": | ||
| 253 | while not rest.endswith(b"0\r\n\r\n"): | ||
| 254 | self.buffer = head + b"\r\n\r\n" + rest | ||
| 255 | self._fill() | ||
| 256 | rest = self.buffer.split(b"\r\n\r\n", 1)[1] | ||
| 257 | body, self.buffer = rest, b"" | ||
| 258 | else: | ||
| 259 | length = int(headers.get(b"content-length", b"0")) | ||
| 260 | while len(rest) < length: | ||
| 261 | self.buffer = head + b"\r\n\r\n" + rest | ||
| 262 | self._fill() | ||
| 263 | rest = self.buffer.split(b"\r\n\r\n", 1)[1] | ||
| 264 | body, self.buffer = rest[:length], rest[length:] | ||
| 265 | return head.split(b"\r\n")[0], headers, body | ||
| 266 | |||
| 267 | def is_open(self): | ||
| 268 | """True if the tracker is still holding the connection.""" | ||
| 269 | try: | ||
| 270 | self.sock.settimeout(0.4) | ||
| 271 | return self.sock.recv(1) != b"" | ||
| 272 | except socket.timeout: | ||
| 273 | return True | ||
| 274 | except OSError: | ||
| 275 | return False | ||
| 276 | finally: | ||
| 277 | try: | ||
| 278 | self.sock.settimeout(5) | ||
| 279 | except OSError: | ||
| 280 | pass | ||
| 281 | |||
| 282 | def close(self): | ||
| 283 | self.sock.close() | ||
| 284 | |||
| 285 | |||
| 215 | class TrackerTestCase(unittest.TestCase): | 286 | class TrackerTestCase(unittest.TestCase): |
| 216 | """Gives every test its own tracker and fails it on any sanitizer output.""" | 287 | """Gives every test its own tracker and fails it on any sanitizer output.""" |
| 217 | 288 | ||
| @@ -222,6 +293,13 @@ class TrackerTestCase(unittest.TestCase): | |||
| 222 | self.tracker = Tracker(self.config, self.files) | 293 | self.tracker = Tracker(self.config, self.files) |
| 223 | 294 | ||
| 224 | def tearDown(self): | 295 | def tearDown(self): |
| 296 | # A connection the test closed is only reaped when the event loop gets | ||
| 297 | # round to it, and a cookie still held at exit reads as a leak. One | ||
| 298 | # more request puts us behind that work in the loop's queue. | ||
| 299 | try: | ||
| 300 | self.tracker.torrent_count() | ||
| 301 | except Exception: | ||
| 302 | pass | ||
| 225 | complaints = self.tracker.stop() | 303 | complaints = self.tracker.stop() |
| 226 | self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints)) | 304 | self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints)) |
| 227 | 305 | ||
diff --git a/tests/integration/test_accesslist.py b/tests/integration/test_accesslist.py index b4e134e..41ff654 100644 --- a/tests/integration/test_accesslist.py +++ b/tests/integration/test_accesslist.py | |||
| @@ -37,12 +37,10 @@ class Blacklist(TrackerTestCase): | |||
| 37 | self.assertNotIn(b"failure reason", answer) | 37 | self.assertNotIn(b"failure reason", answer) |
| 38 | self.assertEqual(answer[b"complete"], 1) | 38 | self.assertEqual(answer[b"complete"], 1) |
| 39 | 39 | ||
| 40 | def test_the_block_holds_over_udp_but_the_answer_is_malformed(self): | 40 | def test_the_block_is_an_error_packet_over_udp(self): |
| 41 | """The UDP path has no spelling for "not authorized": the announce | 41 | """BEP 15 has an error action, which is what a refusal should use. |
| 42 | writes no body, so the tracker sends the eight byte header alone | 42 | It used to answer the announce header with no body behind it, a |
| 43 | where BEP 15 calls for at least twenty. Pinned rather than blessed — | 43 | packet too short for any client to read.""" |
| 44 | an error packet or silence would both be defensible, a short announce | ||
| 45 | is neither.""" | ||
| 46 | sock, connection_id = self.tracker.udp_connect() | 44 | sock, connection_id = self.tracker.udp_connect() |
| 47 | sock.close() | 45 | sock.close() |
| 48 | request = struct.pack( | 46 | request = struct.pack( |
| @@ -50,10 +48,10 @@ class Blacklist(TrackerTestCase): | |||
| 50 | ) | 48 | ) |
| 51 | answer = self.tracker.udp_raw(request) | 49 | answer = self.tracker.udp_raw(request) |
| 52 | self.assertIsNotNone(answer) | 50 | self.assertIsNotNone(answer) |
| 53 | self.assertEqual(len(answer), 8, "header only, no interval or counts") | 51 | action, transaction = struct.unpack(">II", answer[:8]) |
| 54 | action, transaction = struct.unpack(">II", answer) | 52 | self.assertEqual(action, 3, "action 3 is the error packet") |
| 55 | self.assertEqual(action, 1, "it still claims to be an announce") | ||
| 56 | self.assertEqual(transaction, 7) | 53 | self.assertEqual(transaction, 7) |
| 54 | self.assertIn(b"not authorized", answer[8:]) | ||
| 57 | 55 | ||
| 58 | def test_a_blocked_hash_is_not_stored_when_it_arrives_over_udp(self): | 56 | def test_a_blocked_hash_is_not_stored_when_it_arrives_over_udp(self): |
| 59 | sock, connection_id = self.tracker.udp_connect() | 57 | sock, connection_id = self.tracker.udp_connect() |
diff --git a/tests/integration/test_fullscrape.py b/tests/integration/test_fullscrape.py index 4059656..be7b502 100644 --- a/tests/integration/test_fullscrape.py +++ b/tests/integration/test_fullscrape.py | |||
| @@ -112,3 +112,35 @@ class FullScrapeChunking(TrackerTestCase): | |||
| 112 | 112 | ||
| 113 | if __name__ == "__main__": | 113 | if __name__ == "__main__": |
| 114 | unittest.main() | 114 | unittest.main() |
| 115 | |||
| 116 | |||
| 117 | class PartialScrapeOrder(TrackerTestCase): | ||
| 118 | """A bencoded dictionary names its keys in order, once each. The full | ||
| 119 | scrape got that from walking the buckets; the partial scrape used to | ||
| 120 | echo whatever order the client asked in.""" | ||
| 121 | |||
| 122 | def setUp(self): | ||
| 123 | TrackerTestCase.setUp(self) | ||
| 124 | self.hashes = [bytes([i]) + b"\0" * 19 for i in (9, 1, 5, 3, 7)] | ||
| 125 | for i, h in enumerate(self.hashes): | ||
| 126 | self.tracker.announce(h, port=6001 + i, left=0) | ||
| 127 | |||
| 128 | def scrape_many(self, hashes): | ||
| 129 | import urllib.parse | ||
| 130 | |||
| 131 | query = "&".join("info_hash=" + urllib.parse.quote_from_bytes(h, safe="") for h in hashes) | ||
| 132 | return bdecode(self.tracker.get("/scrape?" + query)) | ||
| 133 | |||
| 134 | def test_the_keys_come_back_in_order(self): | ||
| 135 | answer = self.scrape_many(self.hashes) | ||
| 136 | self.assertEqual(list(answer[b"files"]), sorted(self.hashes)) | ||
| 137 | |||
| 138 | def test_reversing_the_question_does_not_reverse_the_answer(self): | ||
| 139 | self.assertEqual( | ||
| 140 | list(self.scrape_many(self.hashes[::-1])[b"files"]), list(self.scrape_many(self.hashes)[b"files"]) | ||
| 141 | ) | ||
| 142 | |||
| 143 | def test_a_hash_asked_for_twice_is_named_once(self): | ||
| 144 | asked = [self.hashes[0], self.hashes[1], self.hashes[0]] | ||
| 145 | answer = self.scrape_many(asked) | ||
| 146 | self.assertEqual(list(answer[b"files"]), sorted({self.hashes[0], self.hashes[1]})) | ||
diff --git a/tests/integration/test_keepalive.py b/tests/integration/test_keepalive.py new file mode 100644 index 0000000..01f02a5 --- /dev/null +++ b/tests/integration/test_keepalive.py | |||
| @@ -0,0 +1,111 @@ | |||
| 1 | """Holding a connection open across several requests. | ||
| 2 | |||
| 3 | The reply path has two shapes. A small answer goes out in one write and the | ||
| 4 | connection is done with immediately. A large one is queued and finishes on a | ||
| 5 | later pass through the event loop, by which time the workstruct that served | ||
| 6 | the request has moved on, so whether to keep the connection has to be | ||
| 7 | remembered on the connection itself. | ||
| 8 | """ | ||
| 9 | |||
| 10 | import unittest | ||
| 11 | import urllib.parse | ||
| 12 | |||
| 13 | from harness import TrackerTestCase, bdecode | ||
| 14 | |||
| 15 | |||
| 16 | class KeepAlive(TrackerTestCase): | ||
| 17 | def announce_path(self, name, port=6881): | ||
| 18 | return "/announce?info_hash=%s&peer_id=%s&port=%d&left=0" % ( | ||
| 19 | urllib.parse.quote_from_bytes(self.hash_for(name), safe=""), | ||
| 20 | "x" * 20, | ||
| 21 | port, | ||
| 22 | ) | ||
| 23 | |||
| 24 | def test_two_requests_over_one_connection(self): | ||
| 25 | c = self.tracker.connect() | ||
| 26 | try: | ||
| 27 | c.send(self.announce_path("kaA")) | ||
| 28 | status, _, first = c.reply() | ||
| 29 | self.assertIn(b"200", status) | ||
| 30 | c.send(self.announce_path("kaB")) | ||
| 31 | status, _, second = c.reply() | ||
| 32 | self.assertIn(b"200", status) | ||
| 33 | self.assertIn(b"complete", bdecode(second)) | ||
| 34 | finally: | ||
| 35 | c.close() | ||
| 36 | |||
| 37 | def test_close_ends_the_connection(self): | ||
| 38 | c = self.tracker.connect() | ||
| 39 | try: | ||
| 40 | c.send(self.announce_path("kaclose"), connection="close") | ||
| 41 | c.reply() | ||
| 42 | self.assertFalse(c.is_open(), "the tracker was asked to hang up") | ||
| 43 | finally: | ||
| 44 | c.close() | ||
| 45 | |||
| 46 | def test_a_connection_survives_a_reply_too_large_for_one_write(self): | ||
| 47 | """The case that was broken: a queued reply finished draining and the | ||
| 48 | connection was closed regardless of what the client had asked for.""" | ||
| 49 | for i in range(400): | ||
| 50 | self.tracker.announce(bytes([i // 256, i % 256]) + b"\0" * 18, port=7000 + i, left=0) | ||
| 51 | |||
| 52 | c = self.tracker.connect() | ||
| 53 | try: | ||
| 54 | c.send("/scrape") | ||
| 55 | status, headers, body = c.reply() | ||
| 56 | self.assertIn(b"200", status) | ||
| 57 | self.assertGreater(len(body), 16384, "this reply has to outgrow one write") | ||
| 58 | |||
| 59 | c.send(self.announce_path("kaafter")) | ||
| 60 | status, _, after = c.reply() | ||
| 61 | self.assertIn(b"200", status) | ||
| 62 | self.assertIn(b"complete", bdecode(after)) | ||
| 63 | finally: | ||
| 64 | c.close() | ||
| 65 | |||
| 66 | def test_several_requests_in_a_row(self): | ||
| 67 | c = self.tracker.connect() | ||
| 68 | try: | ||
| 69 | for i in range(20): | ||
| 70 | c.send(self.announce_path("karow%d" % i)) | ||
| 71 | status, _, body = c.reply() | ||
| 72 | self.assertIn(b"200", status) | ||
| 73 | self.assertEqual(self.tracker.torrent_count(), 20) | ||
| 74 | finally: | ||
| 75 | c.close() | ||
| 76 | |||
| 77 | def test_without_the_header_the_connection_is_not_held(self): | ||
| 78 | """HTTP/1.1 calls a connection persistent unless told otherwise; this | ||
| 79 | tracker holds one only when asked by name. Pinned as it is.""" | ||
| 80 | c = self.tracker.connect() | ||
| 81 | try: | ||
| 82 | c.send(self.announce_path("kanone"), connection=None) | ||
| 83 | c.reply() | ||
| 84 | self.assertFalse(c.is_open()) | ||
| 85 | finally: | ||
| 86 | c.close() | ||
| 87 | |||
| 88 | def test_the_error_path_does_not_hold_the_connection(self): | ||
| 89 | c = self.tracker.connect() | ||
| 90 | try: | ||
| 91 | c.send("/announce?info_hash=short") | ||
| 92 | status, _, _ = c.reply() | ||
| 93 | self.assertIn(b"400", status) | ||
| 94 | finally: | ||
| 95 | c.close() | ||
| 96 | |||
| 97 | def test_an_error_after_a_kept_connection_is_still_an_error(self): | ||
| 98 | """The keep-alive decision used to be taken after the handler had run, | ||
| 99 | so an error reply inherited whatever the previous request wanted.""" | ||
| 100 | c = self.tracker.connect() | ||
| 101 | try: | ||
| 102 | c.send(self.announce_path("kabefore")) | ||
| 103 | self.assertIn(b"200", c.reply()[0]) | ||
| 104 | c.send("/announce?info_hash=short") | ||
| 105 | self.assertIn(b"400", c.reply()[0]) | ||
| 106 | finally: | ||
| 107 | c.close() | ||
| 108 | |||
| 109 | |||
| 110 | if __name__ == "__main__": | ||
| 111 | unittest.main() | ||
diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py index 920d199..820b917 100644 --- a/tests/integration/test_stats.py +++ b/tests/integration/test_stats.py | |||
| @@ -5,7 +5,9 @@ every one of them and, where the number means something, that it moves when | |||
| 5 | the thing it counts happens. | 5 | the thing it counts happens. |
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | import ipaddress | ||
| 8 | import re | 9 | import re |
| 10 | import socket | ||
| 9 | import unittest | 11 | import unittest |
| 10 | import urllib.error | 12 | import urllib.error |
| 11 | import xml.etree.ElementTree | 13 | import xml.etree.ElementTree |
| @@ -14,8 +16,7 @@ from harness import TrackerTestCase | |||
| 14 | 16 | ||
| 15 | # The modes a default build answers. "tpbs" and "statedump" are full scrapes | 17 | # The modes a default build answers. "tpbs" and "statedump" are full scrapes |
| 16 | # and belong to test_fullscrape; "fulllog" and "woodpeckers" sit behind | 18 | # and belong to test_fullscrape; "fulllog" and "woodpeckers" sit behind |
| 17 | # feature flags this build does not set; "busy" is advertised but unbuilt, | 19 | # feature flags this build does not set. |
| 18 | # which the next case pins. | ||
| 19 | MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"] | 20 | MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"] |
| 20 | TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"] | 21 | TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"] |
| 21 | 22 | ||
| @@ -34,14 +35,14 @@ class StatsShape(TrackerTestCase): | |||
| 34 | for line in lines[:2]: | 35 | for line in lines[:2]: |
| 35 | self.assertRegex(line, rb"^\d+$", "mode %s line %r" % (mode, line)) | 36 | self.assertRegex(line, rb"^\d+$", "mode %s line %r" % (mode, line)) |
| 36 | 37 | ||
| 37 | def test_the_busy_mode_is_advertised_but_not_built(self): | 38 | def test_the_busy_mode_is_gone(self): |
| 38 | """TASK_STATS_BUSY_NETWORKS is in the enum and in the mode table, and | 39 | """It was listed in the mode table and implemented nowhere, so it only |
| 39 | no switch anywhere handles it, so the worker frees the empty answer | 40 | ever answered 500. Removed rather than written: picking poisoning |
| 40 | and the request ends as a server error. Pinned rather than blessed: | 41 | networks out stopped being meaningful once consumer lines became bot |
| 41 | either the report gets written or the mode leaves the table.""" | 42 | hosts and v6 made the address space too large to watch.""" |
| 42 | with self.assertRaises(urllib.error.HTTPError) as caught: | 43 | with self.assertRaises(urllib.error.HTTPError) as caught: |
| 43 | self.tracker.stats("busy") | 44 | self.tracker.stats("busy") |
| 44 | self.assertEqual(caught.exception.code, 500) | 45 | self.assertEqual(caught.exception.code, 400, "an unknown mode, like any other") |
| 45 | 46 | ||
| 46 | def test_an_unknown_mode_is_refused(self): | 47 | def test_an_unknown_mode_is_refused(self): |
| 47 | with self.assertRaises(urllib.error.HTTPError) as caught: | 48 | with self.assertRaises(urllib.error.HTTPError) as caught: |
| @@ -131,15 +132,37 @@ class StatsTop(TrackerTestCase): | |||
| 131 | 132 | ||
| 132 | 133 | ||
| 133 | class StatsNetworks(TrackerTestCase): | 134 | class StatsNetworks(TrackerTestCase): |
| 134 | def test_the_slash24_report_names_the_loopback_network(self): | 135 | def source_address(self): |
| 136 | """Which address the tracker sees us from is not ours to choose: a | ||
| 137 | FreeBSD jail relocates a loopback connection to the jail's own | ||
| 138 | address, so ask the socket rather than assume 127.0.0.1.""" | ||
| 139 | probe = socket.create_connection(("127.0.0.1", self.tracker.port), timeout=5) | ||
| 140 | try: | ||
| 141 | return probe.getsockname()[0] | ||
| 142 | finally: | ||
| 143 | probe.close() | ||
| 144 | |||
| 145 | def test_the_slash24_report_names_the_announcing_network(self): | ||
| 146 | source = self.source_address() | ||
| 135 | self.tracker.announce(self.hash_for("s24"), port=6001, left=0) | 147 | self.tracker.announce(self.hash_for("s24"), port=6001, left=0) |
| 136 | body = self.tracker.stats("s24s") | 148 | body = self.tracker.stats("s24s") |
| 137 | self.assertIn(b"Networks, limit /28:", body) | 149 | self.assertIn(b"Networks, limit /28:", body) |
| 138 | self.assertIn(b"Networks, limit /24:", body) | 150 | self.assertIn(b"Networks, limit /24:", body) |
| 139 | # The prefix is masked out of a v4 peer's four address bytes and then | 151 | |
| 140 | # printed through the v6 formatter, so 127.0.0.0/24 reads as "7f00::" | 152 | # The prefix is the first three address bytes of a v4 peer, left |
| 141 | # rather than as an address anyone would recognise. Pinned as it is. | 153 | # aligned in a sixteen byte buffer and then printed by the v6 |
| 142 | self.assertIn(b"7f00::", body) | 154 | # formatter, so 127.0.0.0/24 reads as "7f00::" rather than as the |
| 155 | # network an operator asked about. Pinned as it is, not blessed. | ||
| 156 | prefix = socket.inet_aton(source)[:3] + b"\0" * 13 | ||
| 157 | self.assertIn(str(ipaddress.IPv6Address(prefix)).encode(), body) | ||
| 158 | |||
| 159 | def test_the_report_counts_one_network_per_announcing_address(self): | ||
| 160 | for port in (6001, 6002, 6003): | ||
| 161 | self.tracker.announce(self.hash_for("s24count"), port=port, left=0) | ||
| 162 | lines = [line for line in self.tracker.stats("s24s").splitlines() if b":" in line and b"limit" not in line] | ||
| 163 | self.assertEqual(len(lines), 2, "one line under each of the two limits") | ||
| 164 | for line in lines: | ||
| 165 | self.assertEqual(int(line.split(b":")[0]), 3, "three peers from the one network") | ||
| 143 | 166 | ||
| 144 | 167 | ||
| 145 | if __name__ == "__main__": | 168 | if __name__ == "__main__": |
diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index 1dfa21b..1cb2389 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py | |||
| @@ -149,5 +149,20 @@ class UdpScrape(TrackerTestCase): | |||
| 149 | sock.close() | 149 | sock.close() |
| 150 | 150 | ||
| 151 | 151 | ||
| 152 | class UdpWorkersConfiguredAfterTheListener(TrackerTestCase): | ||
| 153 | """The harness writes its listen lines first, so this directive lands | ||
| 154 | below them: the position a config file should not care about.""" | ||
| 155 | |||
| 156 | config = "listen.udp.workers 4\n" | ||
| 157 | |||
| 158 | def test_the_tracker_starts_and_still_answers_udp(self): | ||
| 159 | sock, connection_id = self.tracker.udp_connect() | ||
| 160 | try: | ||
| 161 | answer = self.tracker.udp_announce(sock, connection_id, self.hash_for("udpworkers"), left=0) | ||
| 162 | self.assertEqual(answer["seeders"], 1) | ||
| 163 | finally: | ||
| 164 | sock.close() | ||
| 165 | |||
| 166 | |||
| 152 | if __name__ == "__main__": | 167 | if __name__ == "__main__": |
| 153 | unittest.main() | 168 | unittest.main() |
