diff options
Diffstat (limited to 'tests/integration/test_fullscrape.py')
| -rw-r--r-- | tests/integration/test_fullscrape.py | 146 |
1 files changed, 146 insertions, 0 deletions
diff --git a/tests/integration/test_fullscrape.py b/tests/integration/test_fullscrape.py new file mode 100644 index 0000000..be7b502 --- /dev/null +++ b/tests/integration/test_fullscrape.py | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | """The full scrape, in every format the tracker offers. | ||
| 2 | |||
| 3 | Each format is a separate writer, and they differ in more than cosmetics: | ||
| 4 | one is binary, one is bencoded, three are line based and one dumps the | ||
| 5 | tracker's own state for a restart. A worker thread produces them in chunks, | ||
| 6 | so these also exercise the chunked transfer the ordinary replies never take. | ||
| 7 | """ | ||
| 8 | |||
| 9 | import gzip | ||
| 10 | import struct | ||
| 11 | import unittest | ||
| 12 | |||
| 13 | from harness import TrackerTestCase, bdecode | ||
| 14 | |||
| 15 | HEX = b"0123456789ABCDEF" | ||
| 16 | |||
| 17 | |||
| 18 | class FullScrape(TrackerTestCase): | ||
| 19 | def setUp(self): | ||
| 20 | TrackerTestCase.setUp(self) | ||
| 21 | self.seeding = self.hash_for("fsA") | ||
| 22 | self.leeching = self.hash_for("fsB") | ||
| 23 | self.tracker.announce(self.seeding, port=6001, left=0) | ||
| 24 | self.tracker.announce(self.leeching, port=6002, left=4096) | ||
| 25 | |||
| 26 | def scrape_all(self, query, headers=None): | ||
| 27 | body, head = self.tracker.get_with_headers(query, headers) | ||
| 28 | if head.get("Content-Encoding") == "gzip": | ||
| 29 | body = gzip.decompress(body) | ||
| 30 | return body | ||
| 31 | |||
| 32 | # --------------------------------------------------------- bencoded | ||
| 33 | |||
| 34 | def test_a_bare_scrape_returns_every_torrent(self): | ||
| 35 | answer = bdecode(self.scrape_all("/scrape")) | ||
| 36 | self.assertIn(self.seeding, answer[b"files"]) | ||
| 37 | self.assertIn(self.leeching, answer[b"files"]) | ||
| 38 | self.assertEqual(answer[b"files"][self.seeding][b"complete"], 1) | ||
| 39 | self.assertEqual(answer[b"files"][self.leeching][b"incomplete"], 1) | ||
| 40 | |||
| 41 | def test_the_bencoded_format_matches_the_bare_scrape(self): | ||
| 42 | self.assertEqual(bdecode(self.scrape_all("/stats?mode=tpbs&format=ben")), bdecode(self.scrape_all("/scrape"))) | ||
| 43 | |||
| 44 | def test_a_full_scrape_can_be_gzipped(self): | ||
| 45 | plain = self.scrape_all("/scrape") | ||
| 46 | body, head = self.tracker.get_with_headers("/scrape", {"Accept-Encoding": "gzip"}) | ||
| 47 | self.assertEqual(head.get("Content-Encoding"), "gzip") | ||
| 48 | self.assertEqual(gzip.decompress(body), plain) | ||
| 49 | |||
| 50 | # ----------------------------------------------------------- binary | ||
| 51 | |||
| 52 | def test_the_binary_format_is_a_hash_and_two_counts(self): | ||
| 53 | body = self.scrape_all("/stats?mode=tpbs&format=bin") | ||
| 54 | self.assertEqual(len(body) % 28, 0, "twenty byte hash plus two counts") | ||
| 55 | entries = {body[i : i + 20]: struct.unpack(">II", body[i + 20 : i + 28]) for i in range(0, len(body), 28)} | ||
| 56 | self.assertEqual(entries[self.seeding], (1, 0), "one seeder, no leechers") | ||
| 57 | self.assertEqual(entries[self.leeching], (0, 1), "no seeders, one leecher") | ||
| 58 | |||
| 59 | # ------------------------------------------------------- line based | ||
| 60 | |||
| 61 | def entries_from_lines(self, body): | ||
| 62 | return {line.split(b":")[0]: line.split(b":")[1:] for line in body.splitlines() if line} | ||
| 63 | |||
| 64 | def test_the_text_format_is_hex_colon_seeders_colon_leechers(self): | ||
| 65 | entries = self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txt")) | ||
| 66 | self.assertEqual(entries[self.seeding.hex().upper().encode()], [b"1", b"0"]) | ||
| 67 | self.assertEqual(entries[self.leeching.hex().upper().encode()], [b"0", b"1"]) | ||
| 68 | |||
| 69 | def test_the_text_plus_format_adds_the_completed_count(self): | ||
| 70 | entries = self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txtp")) | ||
| 71 | self.assertEqual(entries[self.seeding.hex().upper().encode()], [b"1", b"0", b"0"]) | ||
| 72 | |||
| 73 | def test_the_urlencoded_format_escapes_the_hash(self): | ||
| 74 | body = self.scrape_all("/stats?mode=tpbs&format=url") | ||
| 75 | lines = [line for line in body.splitlines() if line] | ||
| 76 | self.assertEqual(len(lines), 2) | ||
| 77 | for line in lines: | ||
| 78 | escaped = line.rsplit(b":", 2)[0] | ||
| 79 | self.assertIn(b"%", escaped, "these hashes are mostly unprintable bytes") | ||
| 80 | |||
| 81 | def test_the_state_dump_carries_a_base_time_rather_than_a_peer_count(self): | ||
| 82 | """What a restart reads back, so its second column is the torrent's | ||
| 83 | own clock and not a count of anybody.""" | ||
| 84 | entries = self.entries_from_lines(self.scrape_all("/stats?mode=statedump")) | ||
| 85 | self.assertIn(self.seeding.hex().upper().encode(), entries) | ||
| 86 | base, completed = entries[self.seeding.hex().upper().encode()] | ||
| 87 | self.assertGreater(int(base), 0) | ||
| 88 | self.assertEqual(int(completed), 0) | ||
| 89 | |||
| 90 | # ------------------------------------------------------------ shape | ||
| 91 | |||
| 92 | def test_every_format_sees_the_same_torrents(self): | ||
| 93 | counts = { | ||
| 94 | "ben": len(bdecode(self.scrape_all("/stats?mode=tpbs&format=ben"))[b"files"]), | ||
| 95 | "bin": len(self.scrape_all("/stats?mode=tpbs&format=bin")) // 28, | ||
| 96 | "txt": len(self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txt"))), | ||
| 97 | "txtp": len(self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txtp"))), | ||
| 98 | "url": len([l for l in self.scrape_all("/stats?mode=tpbs&format=url").splitlines() if l]), | ||
| 99 | } | ||
| 100 | self.assertEqual(set(counts.values()), {2}, "all formats, two torrents: %r" % counts) | ||
| 101 | |||
| 102 | |||
| 103 | class FullScrapeChunking(TrackerTestCase): | ||
| 104 | def test_a_scrape_larger_than_one_chunk_arrives_whole(self): | ||
| 105 | """The writer hands the socket a chunk at a time, so a scrape that | ||
| 106 | spans several is the case where the seams show.""" | ||
| 107 | for i in range(400): | ||
| 108 | self.tracker.announce(struct.pack(">I", i) + b"\0" * 16, port=7000 + i, left=0) | ||
| 109 | answer = bdecode(self.tracker.get("/scrape")) | ||
| 110 | self.assertEqual(len(answer[b"files"]), 400) | ||
| 111 | |||
| 112 | |||
| 113 | if __name__ == "__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]})) | ||
