From ee7538e03a76dbacb5599ed19810ae9b99605f13 Mon Sep 17 00:00:00 2001 From: "Sergey.Pak" Date: Wed, 17 Jul 2013 20:48:05 +0400 Subject: [PATCH] Added tests and slightly updated code for convenience --- .../com/turn/ttorrent/common/Torrent.java | 23 +- .../com/turn/ttorrent/tracker/Tracker.java | 27 +- src/test/java/com/turn/ttorrent/FileUtil.java | 56 ++++ .../java/com/turn/ttorrent/TempFiles.java | 136 +++++++++ src/test/java/com/turn/ttorrent/WaitFor.java | 33 +++ .../com/turn/ttorrent/common/TorrentTest.java | 30 ++ .../turn/ttorrent/tracker/TrackerTest.java | 260 ++++++++++++++++++ src/test/resources/parentFiles/file1.jar | Bin 0 -> 559043 bytes src/test/resources/parentFiles/file2.jar | Bin 0 -> 737568 bytes src/test/resources/torrents/file1.jar.torrent | Bin 0 -> 894 bytes src/test/resources/torrents/file2.jar.torrent | Bin 0 -> 1114 bytes 11 files changed, 552 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/turn/ttorrent/FileUtil.java create mode 100644 src/test/java/com/turn/ttorrent/TempFiles.java create mode 100644 src/test/java/com/turn/ttorrent/WaitFor.java create mode 100644 src/test/java/com/turn/ttorrent/common/TorrentTest.java create mode 100644 src/test/java/com/turn/ttorrent/tracker/TrackerTest.java create mode 100644 src/test/resources/parentFiles/file1.jar create mode 100644 src/test/resources/parentFiles/file2.jar create mode 100644 src/test/resources/torrents/file1.jar.torrent create mode 100644 src/test/resources/torrents/file2.jar.torrent diff --git a/src/main/java/com/turn/ttorrent/common/Torrent.java b/src/main/java/com/turn/ttorrent/common/Torrent.java index e468dc560..1f698e074 100644 --- a/src/main/java/com/turn/ttorrent/common/Torrent.java +++ b/src/main/java/com/turn/ttorrent/common/Torrent.java @@ -133,7 +133,6 @@ public TorrentFile(File file, long size) { * BitTorrent specification) and create a Torrent object from it. * * @param torrent The meta-info byte data. - * @param parent The parent directory or location of the torrent files. * @param seeder Whether we'll be seeding for this torrent or not. * @throws IOException When the info dictionary can't be read or * encoded and hashed back to create the torrent's SHA-1 hash. @@ -402,14 +401,22 @@ public boolean isSeeder() { return this.seeder; } - /** + /** * Save this torrent meta-info structure into a .torrent file. * - * @param output The stream to write to. + * @param file The file to write to. * @throws IOException If an I/O error occurs while writing the file. */ - public void save(OutputStream output) throws IOException { - output.write(this.getEncoded()); + public void save(File file) throws IOException { + FileOutputStream fOut = null; + try { + fOut = new FileOutputStream(file); + fOut.write(this.getEncoded()); + } finally { + if (fOut != null){ + fOut.close(); + } + } } public static byte[] hash(byte[] data) throws NoSuchAlgorithmException { @@ -592,7 +599,7 @@ public static Torrent create(File source, List> announceList, * considering we'll be a full initial seeder for it. *

* - * @param parent The parent directory or location of the torrent files, + * @param source The parent directory or location of the torrent files, * also used as the torrent's name. * @param files The files to add into this torrent. * @param announceList The announce URIs organized as tiers that will @@ -954,8 +961,8 @@ public static void main(String[] args) { torrent = Torrent.create(source, announceURI, creator); } - torrent.save(fos); - } else { + fos.write(torrent.getEncoded()); + } else { Torrent.load(new File(filenameValue), true); } } catch (Exception e) { diff --git a/src/main/java/com/turn/ttorrent/tracker/Tracker.java b/src/main/java/com/turn/ttorrent/tracker/Tracker.java index 6ba05d8b1..94174697d 100644 --- a/src/main/java/com/turn/ttorrent/tracker/Tracker.java +++ b/src/main/java/com/turn/ttorrent/tracker/Tracker.java @@ -25,6 +25,9 @@ import java.net.InetSocketAddress; import java.net.MalformedURLException; import java.net.URL; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.List; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ConcurrentHashMap; @@ -48,7 +51,7 @@ *

* The tracker usually listens on port 6969 (the standard BitTorrent tracker * port). Torrents must be registered directly to this tracker with the - * {@link #announce(TrackedTorrent torrent)} method. + * {@link #announce(Torrent torrent)} method. *

* * @author mpetazzoni @@ -180,7 +183,15 @@ public void stop() { } } - /** + public ConcurrentMap getTorrentsMap() { + return torrents; + } + + public Collection getTrackedTorrents(){ + return torrents.values(); + } + + /** * Announce a new torrent on this tracker. * *

@@ -195,7 +206,7 @@ public void stop() { * different from the supplied Torrent object if the tracker already * contained a torrent with the same hash. */ - public synchronized TrackedTorrent announce(TrackedTorrent torrent) { + public synchronized TrackedTorrent announce(Torrent torrent) throws IOException, NoSuchAlgorithmException { TrackedTorrent existing = this.torrents.get(torrent.getHexInfoHash()); if (existing != null) { @@ -204,10 +215,16 @@ public synchronized TrackedTorrent announce(TrackedTorrent torrent) { return existing; } - this.torrents.put(torrent.getHexInfoHash(), torrent); + final TrackedTorrent result; + if (torrent instanceof TrackedTorrent) { + result = (TrackedTorrent) torrent; + } else { + result = new TrackedTorrent(torrent); + } + this.torrents.put(torrent.getHexInfoHash(), result); logger.info("Registered new torrent for '{}' with hash {}.", torrent.getName(), torrent.getHexInfoHash()); - return torrent; + return result; } /** diff --git a/src/test/java/com/turn/ttorrent/FileUtil.java b/src/test/java/com/turn/ttorrent/FileUtil.java new file mode 100644 index 000000000..3abff5e1d --- /dev/null +++ b/src/test/java/com/turn/ttorrent/FileUtil.java @@ -0,0 +1,56 @@ +package com.turn.ttorrent; + +import java.io.Closeable; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +public class FileUtil { + + public static File getTempDirectory() { + return new File(System.getProperty("java.io.tmpdir")); + } + + public static void delete(File file) { + if (file.isDirectory()) { + File[] files = file.listFiles(); + for (File f: files) { + delete(f); + } + } + deleteFile(file); + } + + private static boolean deleteFile(File file) { + if (!file.exists()) return false; + for (int i=0; i<10; i++) { + if (file.delete()) return true; + try { + Thread.sleep(1); + } catch (InterruptedException e) { + // + } + } + return false; + } + + public static void writeFile(File file, String content) throws IOException { + FileWriter fw = null; + try { + fw = new FileWriter(file); + fw.write(content); + } finally { + close(fw); + } + } + + public static void close(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + // + } + } + } +} diff --git a/src/test/java/com/turn/ttorrent/TempFiles.java b/src/test/java/com/turn/ttorrent/TempFiles.java new file mode 100644 index 000000000..bf0953d1a --- /dev/null +++ b/src/test/java/com/turn/ttorrent/TempFiles.java @@ -0,0 +1,136 @@ +package com.turn.ttorrent; + +import java.io.*; +import java.util.*; + +public class TempFiles { + private static final File ourCurrentTempDir = FileUtil.getTempDirectory(); + private final File myCurrentTempDir; + + private static Random ourRandom; + + static { + ourRandom = new Random(); + ourRandom.setSeed(System.currentTimeMillis()); + } + + private final List myFilesToDelete = new ArrayList(); + private final Thread myShutdownHook; + private volatile boolean myInsideShutdownHook; + + public TempFiles() { + myCurrentTempDir = ourCurrentTempDir; + if (!myCurrentTempDir.isDirectory()) { + + throw new IllegalStateException("Temp directory is not a directory, was deleted by some process: " + myCurrentTempDir.getAbsolutePath() + + "\njava.io.tmpdir: " + FileUtil.getTempDirectory()); + } + + myShutdownHook = new Thread(new Runnable() { + public void run() { + myInsideShutdownHook = true; + cleanup(); + } + }); + Runtime.getRuntime().addShutdownHook(myShutdownHook); + } + + private File doCreateTempDir(String prefix, String suffix) throws IOException { + prefix = prefix == null ? "" : prefix; + suffix = suffix == null ? ".tmp" : suffix; + + do { + int count = ourRandom.nextInt(); + final File f = new File(myCurrentTempDir, prefix + count + suffix); + if (!f.exists() && f.mkdirs()) { + return f.getCanonicalFile(); + } + } while (true); + + } + private File doCreateTempFile(String prefix, String suffix) throws IOException { + final File file = doCreateTempDir(prefix, suffix); + file.delete(); + file.createNewFile(); + return file; + } + + public final File createTempFile(String content) throws IOException { + File tempFile = createTempFile(); + FileUtil.writeFile(tempFile, content); + return tempFile; + } + + public final File createTempFile() throws IOException { + File tempFile = doCreateTempFile("test", null); + registerAsTempFile(tempFile); + return tempFile; + } + + public void registerAsTempFile(final File tempFile) { + myFilesToDelete.add(tempFile); + } + + public final File createTempFile(int size) throws IOException { + File tempFile = createTempFile(); + int bufLen = Math.min(8 * 1024, size); + if (bufLen == 0) return tempFile; + final OutputStream fos = new BufferedOutputStream(new FileOutputStream(tempFile)); + try { + byte[] buf = new byte[bufLen]; + for (int i=0; i < buf.length; i++) { + buf[i] = (byte)Math.round(Math.random()*128); + } + + int numWritten = 0; + for (int i=0; i numWritten) { + fos.write(buf, 0, size - numWritten); + } + } finally { + fos.close(); + } + + return tempFile; + } + + /** + * Returns a File object for created temp directory. + * Also stores the value into this object accessed with {@link #getCurrentTempDir()} + * + * @return a File object for created temp directory + * @throws IOException if directory creation fails. + */ + public final File createTempDir() throws IOException { + File f = doCreateTempDir("test", ""); + registerAsTempFile(f); + return f; + } + + /** + * Returns the current directory used by the test or null if no test is running or no directory is created yet. + * + * @return see above + */ + public File getCurrentTempDir() { + return myCurrentTempDir; + } + + public void cleanup() { + try { + for (File file : myFilesToDelete) { + FileUtil.delete(file); + } + + myFilesToDelete.clear(); + } finally { + if (!myInsideShutdownHook) { + Runtime.getRuntime().removeShutdownHook(myShutdownHook); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/turn/ttorrent/WaitFor.java b/src/test/java/com/turn/ttorrent/WaitFor.java new file mode 100644 index 000000000..41ab425ed --- /dev/null +++ b/src/test/java/com/turn/ttorrent/WaitFor.java @@ -0,0 +1,33 @@ +package com.turn.ttorrent; + +public abstract class WaitFor { + private long myPollInterval = 100; + + protected WaitFor() { + this(40 * 1000); + } + + protected WaitFor(long timeout) { + long started = System.currentTimeMillis(); + try { + while(true) { + if (condition()) return; + if (System.currentTimeMillis() - started < timeout) { + Thread.sleep(myPollInterval); + } else { + break; + } + } + + } catch (InterruptedException e) { + //NOP + } + } + + protected WaitFor(long timeout, long pollInterval) { + this(timeout); + myPollInterval = pollInterval; + } + + protected abstract boolean condition(); +} diff --git a/src/test/java/com/turn/ttorrent/common/TorrentTest.java b/src/test/java/com/turn/ttorrent/common/TorrentTest.java new file mode 100644 index 000000000..666fde629 --- /dev/null +++ b/src/test/java/com/turn/ttorrent/common/TorrentTest.java @@ -0,0 +1,30 @@ +package com.turn.ttorrent.common; + +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; + +import static org.testng.Assert.assertEquals; + +@Test +public class TorrentTest { + + public void test_create_torrent() throws URISyntaxException, IOException, NoSuchAlgorithmException, InterruptedException { + URI announceURI = new URI("http://localhost:6969/announce"); + String createdBy = "Test"; + Torrent t = Torrent.create(new File("src/test/resources/parentFiles/file1.jar"), announceURI, createdBy); + assertEquals(createdBy, t.getCreatedBy()); + assertEquals(announceURI, t.getAnnounceList().get(0).get(0)); + } + + public void load_torrent_created_by_utorrent() throws IOException, NoSuchAlgorithmException, URISyntaxException { + Torrent t = Torrent.load(new File("src/test/resources/torrents/file1.jar.torrent")); + assertEquals(new URI("http://localhost:6969/announce"), t.getAnnounceList().get(0).get(0)); + assertEquals("B92D38046C76D73948E14C42DF992CAF25489D08", t.getHexInfoHash()); + assertEquals("uTorrent/3130", t.getCreatedBy()); + } +} diff --git a/src/test/java/com/turn/ttorrent/tracker/TrackerTest.java b/src/test/java/com/turn/ttorrent/tracker/TrackerTest.java new file mode 100644 index 000000000..7742ea48a --- /dev/null +++ b/src/test/java/com/turn/ttorrent/tracker/TrackerTest.java @@ -0,0 +1,260 @@ +package com.turn.ttorrent.tracker; + +import com.turn.ttorrent.TempFiles; +import com.turn.ttorrent.WaitFor; +import com.turn.ttorrent.client.Client; +import com.turn.ttorrent.client.SharedTorrent; +import com.turn.ttorrent.common.Torrent; +import org.apache.commons.io.FileUtils; +import org.apache.log4j.BasicConfigurator; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.zip.CRC32; +import java.util.zip.Checksum; + +import static org.testng.Assert.*; + +@Test +public class TrackerTest { + private static final String TEST_RESOURCES = "src/test/resources"; + private Tracker tracker; + private TempFiles tempFiles; + + @BeforeMethod + protected void setUp() throws Exception { + BasicConfigurator.configure(); + tempFiles = new TempFiles(); + startTracker(); + } + + public void test_share_and_download() throws IOException, NoSuchAlgorithmException, InterruptedException { + final TrackedTorrent tt = this.tracker.announce(loadTorrent("file1.jar.torrent")); + assertEquals(0, tt.getPeers().size()); + + Client seeder = createClient(completeTorrent("file1.jar.torrent")); + + assertEquals(tt.getHexInfoHash(), seeder.getTorrent().getHexInfoHash()); + + final File downloadDir = tempFiles.createTempDir(); + Client leech = createClient(incompleteTorrent("file1.jar.torrent", downloadDir)); + + try { + seeder.share(); + + leech.download(); + + waitForFileInDir(downloadDir, "file1.jar"); + assertFilesEqual(new File(TEST_RESOURCES + "/parentFiles/file1.jar"), new File(downloadDir, "file1.jar")); + } finally { + leech.stop(true); + seeder.stop(true); + } + } + + public void tracker_accepts_torrent_from_seeder() throws IOException, NoSuchAlgorithmException, InterruptedException { + final SharedTorrent torrent = completeTorrent("file1.jar.torrent"); + tracker.announce(torrent); + Client seeder = createClient(torrent); + + try { + seeder.share(); + + waitForSeeder(seeder.getTorrent().getInfoHash()); + + Collection trackedTorrents = this.tracker.getTrackedTorrents(); + assertEquals(1, trackedTorrents.size()); + + TrackedTorrent trackedTorrent = trackedTorrents.iterator().next(); + Map peers = trackedTorrent.getPeers(); + assertEquals(1, peers.size()); + assertTrue(peers.values().iterator().next().isCompleted()); // seed + assertEquals(1, trackedTorrent.seeders()); + assertEquals(0, trackedTorrent.leechers()); + } finally { + seeder.stop(true); + } + } + + public void tracker_accepts_torrent_from_leech() throws IOException, NoSuchAlgorithmException, InterruptedException { + + final File downloadDir = tempFiles.createTempDir(); + final SharedTorrent torrent = incompleteTorrent("file1.jar.torrent", downloadDir); + tracker.announce(torrent); + Client leech = createClient(torrent); + + try { + leech.download(); + + waitForPeers(1); + + Collection trackedTorrents = this.tracker.getTrackedTorrents(); + assertEquals(1, trackedTorrents.size()); + + TrackedTorrent trackedTorrent = trackedTorrents.iterator().next(); + Map peers = trackedTorrent.getPeers(); + assertEquals(1, peers.size()); + assertFalse(peers.values().iterator().next().isCompleted()); // leech + assertEquals(0, trackedTorrent.seeders()); + assertEquals(1, trackedTorrent.leechers()); + } finally { + leech.stop(true); + } + } + + public void tracker_accepts_torrent_from_seeder_plus_leech() throws IOException, NoSuchAlgorithmException, InterruptedException { + assertEquals(0, this.tracker.getTrackedTorrents().size()); + + final SharedTorrent completeTorrent = completeTorrent("file1.jar.torrent"); + tracker.announce(completeTorrent); + Client seeder = createClient(completeTorrent); + + final File downloadDir = tempFiles.createTempDir(); + final SharedTorrent incompleteTorrent = incompleteTorrent("file1.jar.torrent", downloadDir); + Client leech = createClient(incompleteTorrent); + + try { + seeder.share(); + leech.download(); + + waitForFileInDir(downloadDir, "file1.jar"); + } finally { + seeder.stop(true); + leech.stop(true); + } + } + + private Set listFileNames(File downloadDir) { + if (downloadDir == null) return Collections.emptySet(); + Set names = new HashSet(); + File[] files = downloadDir.listFiles(); + if (files == null) return Collections.emptySet(); + for (File f: files) { + names.add(f.getName()); + } + return names; + } + + + public void large_file_download() throws IOException, URISyntaxException, NoSuchAlgorithmException, InterruptedException { + + + File tempFile = tempFiles.createTempFile(201 * 1024 * 1024); + + Torrent torrent = Torrent.create(tempFile, this.tracker.getAnnounceUrl().toURI(), "Test"); + File torrentFile = new File(tempFile.getParentFile(), tempFile.getName() + ".torrent"); + torrent.save(torrentFile); + tracker.announce(torrent); + + Client seeder = createClient(SharedTorrent.fromFile(torrentFile, tempFile.getParentFile())); + + final File downloadDir = tempFiles.createTempDir(); + Client leech = createClient(SharedTorrent.fromFile(torrentFile, downloadDir)); + + try { + seeder.share(); + leech.download(); + + waitForFileInDir(downloadDir, tempFile.getName()); + assertFilesEqual(tempFile, new File(downloadDir, tempFile.getName())); + } finally { + seeder.stop(true); + leech.stop(true); + } + } + + public void test_announce() throws IOException, NoSuchAlgorithmException { + assertEquals(0, this.tracker.getTrackedTorrents().size()); + + this.tracker.announce(loadTorrent("file1.jar.torrent")); + + assertEquals(1, this.tracker.getTrackedTorrents().size()); + } + + private void waitForSeeder(final byte[] torrentHash) { + new WaitFor() { + @Override + protected boolean condition() { + for (TrackedTorrent tt: TrackerTest.this.tracker.getTrackedTorrents()) { + if (tt.seeders() == 1 && tt.getHexInfoHash().equals(Torrent.byteArrayToHexString(torrentHash))) return true; + } + + return false; + } + }; + } + + private void waitForPeers(final int numPeers) { + new WaitFor() { + @Override + protected boolean condition() { + for (TrackedTorrent tt: TrackerTest.this.tracker.getTrackedTorrents()) { + if (tt.getPeers().size() == numPeers) return true; + } + + return false; + } + }; + } + + private void waitForFileInDir(final File downloadDir, final String fileName) { + new WaitFor() { + @Override + protected boolean condition() { + return new File(downloadDir, fileName).isFile(); + } + }; + + assertTrue(new File(downloadDir, fileName).isFile()); + } + + private TrackedTorrent loadTorrent(String name) throws IOException, NoSuchAlgorithmException { + return new TrackedTorrent(Torrent.load(new File(TEST_RESOURCES + "/torrents", name), true)); + } + + + @AfterMethod + protected void tearDown() throws Exception { + stopTracker(); + tempFiles.cleanup(); + } + + private void startTracker() throws IOException { + this.tracker = new Tracker(new InetSocketAddress(6969)); + this.tracker.start(); + } + + private Client createClient(SharedTorrent torrent) throws IOException, NoSuchAlgorithmException, InterruptedException { + return new Client(InetAddress.getLocalHost(), torrent); + } + + private SharedTorrent completeTorrent(String name) throws IOException, NoSuchAlgorithmException { + File torrentFile = new File(TEST_RESOURCES + "/torrents", name); + File parentFiles = new File(TEST_RESOURCES + "/parentFiles"); + return SharedTorrent.fromFile(torrentFile, parentFiles); + } + + private SharedTorrent incompleteTorrent(String name, File destDir) throws IOException, NoSuchAlgorithmException { + File torrentFile = new File(TEST_RESOURCES + "/torrents", name); + return SharedTorrent.fromFile(torrentFile, destDir); + } + + private void stopTracker() { + this.tracker.stop(); + } + + private void assertFilesEqual(File f1, File f2) throws IOException { + assertEquals(f1.length(), f2.length(), "Files size differs"); + Checksum c1 = FileUtils.checksum(f1, new CRC32()); + Checksum c2 = FileUtils.checksum(f2, new CRC32()); + assertEquals(c1.getValue(), c2.getValue()); + } +} diff --git a/src/test/resources/parentFiles/file1.jar b/src/test/resources/parentFiles/file1.jar new file mode 100644 index 0000000000000000000000000000000000000000..add2577008be18b206ebed8c6f769c98f5054c71 GIT binary patch literal 559043 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