Merge branch 'last-working' into next
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Fri, 12 Jun 2015 18:47:55 +0000 (20:47 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Fri, 12 Jun 2015 21:01:04 +0000 (23:01 +0200)
This is the current state with lots and lots of changes since the last
release. I have been using this state during the last year and it should
work pretty much the same as before (minus a couple of bugs).

Conflicts:
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/freenet/PluginStoreConfigurationBackend.java

1  2 
pom.xml
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneDownloaderImpl.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/freenet/PluginStoreConfigurationBackend.java
src/main/java/net/pterodactylus/sone/main/SonePlugin.java
src/test/java/net/pterodactylus/sone/core/FreenetInterfaceTest.java
src/test/java/net/pterodactylus/sone/core/SoneInserterTest.java
src/test/java/net/pterodactylus/sone/core/UpdateCheckerTest.java

diff --cc pom.xml
+++ b/pom.xml
                        <scope>test</scope>
                </dependency>
                <dependency>
+                       <groupId>org.hamcrest</groupId>
+                       <artifactId>hamcrest-all</artifactId>
+                       <version>1.3</version>
+               </dependency>
+               <dependency>
                        <groupId>org.freenetproject</groupId>
                        <artifactId>fred</artifactId>
 -                      <version>0.7.5.1405</version>
 +                      <version>0.7.5.1467.99.3</version>
                        <scope>provided</scope>
                </dependency>
                <dependency>
@@@ -32,17 -37,18 +37,17 @@@ import net.pterodactylus.sone.core.even
  import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.data.TemporaryImage;
- import net.pterodactylus.util.logging.Logging;
  
 -import com.db4o.ObjectContainer;
 -
+ import com.google.common.base.Function;
  import com.google.common.eventbus.EventBus;
  import com.google.inject.Inject;
+ import com.google.inject.Singleton;
  
  import freenet.client.ClientMetadata;
  import freenet.client.FetchException;
 +import freenet.client.FetchException.FetchExceptionMode;
  import freenet.client.FetchResult;
  import freenet.client.HighLevelSimpleClient;
- import freenet.client.HighLevelSimpleClientImpl;
  import freenet.client.InsertBlock;
  import freenet.client.InsertContext;
  import freenet.client.InsertException;
@@@ -129,11 -122,10 +135,10 @@@ public class FreenetInterface 
                FreenetURI currentUri = new FreenetURI(uri);
                while (true) {
                        try {
-                               fetchResult = client.fetch(currentUri);
+                               FetchResult fetchResult = client.fetch(currentUri);
                                return new Fetched(currentUri, fetchResult);
                        } catch (FetchException fe1) {
 -                              if (fe1.getMode() == FetchException.PERMANENT_REDIRECT) {
 +                              if (fe1.getMode() == FetchExceptionMode.PERMANENT_REDIRECT) {
                                        currentUri = fe1.newURI;
                                        continue;
                                }
                 */
                @SuppressWarnings("synthetic-access")
                public void cancel() {
 -                      clientPutter.cancel(null, node.clientCore.clientContext);
 +                      clientPutter.cancel(node.clientCore.clientContext);
                        eventBus.post(new ImageInsertAbortedEvent(image));
++                      bucket.free();
                }
  
                //
index 0000000,ff0eaf7..b36c2d3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,275 +1,274 @@@
 -                                      ObjectContainer objectContainer,
+ /*
+  * Sone - SoneDownloader.java - Copyright © 2010–2013 David Roden
+  *
+  * This program is free software: you can redistribute it and/or modify
+  * it under the terms of the GNU General Public License as published by
+  * the Free Software Foundation, either version 3 of the License, or
+  * (at your option) any later version.
+  *
+  * This program is distributed in the hope that it will be useful,
+  * but WITHOUT ANY WARRANTY; without even the implied warranty of
+  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  * GNU General Public License for more details.
+  *
+  * You should have received a copy of the GNU General Public License
+  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  */
+ package net.pterodactylus.sone.core;
+ import static freenet.support.io.Closer.close;
+ import static java.lang.String.format;
+ import static java.lang.System.currentTimeMillis;
+ import static java.util.concurrent.TimeUnit.DAYS;
+ import static java.util.logging.Logger.getLogger;
+ import java.io.InputStream;
+ import java.util.HashSet;
+ import java.util.Set;
+ import java.util.logging.Level;
+ import java.util.logging.Logger;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.data.Sone.SoneStatus;
+ import net.pterodactylus.util.service.AbstractService;
+ import freenet.client.FetchResult;
+ import freenet.client.async.ClientContext;
+ import freenet.client.async.USKCallback;
+ import freenet.keys.FreenetURI;
+ import freenet.keys.USK;
+ import freenet.node.RequestStarter;
+ import freenet.support.api.Bucket;
+ import freenet.support.io.Closer;
+ import com.db4o.ObjectContainer;
+ import com.google.common.annotations.VisibleForTesting;
+ /**
+  * The Sone downloader is responsible for download Sones as they are updated.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class SoneDownloaderImpl extends AbstractService implements SoneDownloader {
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.Downloader");
+       /** The maximum protocol version. */
+       private static final int MAX_PROTOCOL_VERSION = 0;
+       /** The core. */
+       private final Core core;
+       private final SoneParser soneParser;
+       /** The Freenet interface. */
+       private final FreenetInterface freenetInterface;
+       /** The sones to update. */
+       private final Set<Sone> sones = new HashSet<Sone>();
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        */
+       public SoneDownloaderImpl(Core core, FreenetInterface freenetInterface) {
+               this(core, freenetInterface, new SoneParser(core));
+       }
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        * @param soneParser
+        */
+       @VisibleForTesting
+       SoneDownloaderImpl(Core core, FreenetInterface freenetInterface, SoneParser soneParser) {
+               super("Sone Downloader", false);
+               this.core = core;
+               this.freenetInterface = freenetInterface;
+               this.soneParser = soneParser;
+       }
+       //
+       // ACTIONS
+       //
+       /**
+        * Adds the given Sone to the set of Sones that will be watched for updates.
+        *
+        * @param sone
+        *              The Sone to add
+        */
+       @Override
+       public void addSone(final Sone sone) {
+               if (!sones.add(sone)) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+               final USKCallback uskCallback = new USKCallback() {
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public void onFoundEdition(long edition, USK key,
+                                       ClientContext clientContext, boolean metadata,
+                                       short codec, byte[] data, boolean newKnownGood,
+                                       boolean newSlotToo) {
+                               logger.log(Level.FINE, format(
+                                               "Found USK update for Sone “%s” at %s, new known good: %s, new slot too: %s.",
+                                               sone, key, newKnownGood, newSlotToo));
+                               if (edition > sone.getLatestEdition()) {
+                                       sone.setLatestEdition(edition);
+                                       new Thread(fetchSoneAction(sone),
+                                                       "Sone Downloader").start();
+                               }
+                       }
+                       @Override
+                       public short getPollingPriorityProgress() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+                       @Override
+                       public short getPollingPriorityNormal() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+               };
+               if (soneHasBeenActiveRecently(sone)) {
+                       freenetInterface.registerActiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               } else {
+                       freenetInterface.registerPassiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               }
+       }
+       private boolean soneHasBeenActiveRecently(Sone sone) {
+               return (currentTimeMillis() - sone.getTime()) < DAYS.toMillis(7);
+       }
+       private void fetchSone(Sone sone) {
+               fetchSone(sone, sone.getRequestUri().sskForUSK());
+       }
+       /**
+        * Fetches the updated Sone. This method can be used to fetch a Sone from a
+        * specific URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI to fetch the Sone from
+        */
+       @Override
+       public void fetchSone(Sone sone, FreenetURI soneUri) {
+               fetchSone(sone, soneUri, false);
+       }
+       /**
+        * Fetches the Sone from the given URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI of the Sone to fetch
+        * @param fetchOnly
+        *              {@code true} to only fetch and parse the Sone, {@code false}
+        *              to {@link Core#updateSone(Sone) update} it in the core
+        * @return The downloaded Sone, or {@code null} if the Sone could not be
+        *         downloaded
+        */
+       @Override
+       public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
+               logger.log(Level.FINE, String.format("Starting fetch for Sone “%s” from %s…", sone, soneUri));
+               FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
+               sone.setStatus(SoneStatus.downloading);
+               try {
+                       Fetched fetchResults = freenetInterface.fetchUri(requestUri);
+                       if (fetchResults == null) {
+                               /* TODO - mark Sone as bad. */
+                               return null;
+                       }
+                       logger.log(Level.FINEST, String.format("Got %d bytes back.", fetchResults.getFetchResult().size()));
+                       Sone parsedSone = parseSone(sone, fetchResults.getFetchResult(), fetchResults.getFreenetUri());
+                       if (parsedSone != null) {
+                               if (!fetchOnly) {
+                                       parsedSone.setStatus((parsedSone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+                                       core.updateSone(parsedSone);
+                                       addSone(parsedSone);
+                               }
+                       }
+                       return parsedSone;
+               } finally {
+                       sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+               }
+       }
+       /**
+        * Parses a Sone from a fetch result.
+        *
+        * @param originalSone
+        *              The sone to parse, or {@code null} if the Sone is yet unknown
+        * @param fetchResult
+        *              The fetch result
+        * @param requestUri
+        *              The requested URI
+        * @return The parsed Sone, or {@code null} if the Sone could not be parsed
+        */
+       private Sone parseSone(Sone originalSone, FetchResult fetchResult, FreenetURI requestUri) {
+               logger.log(Level.FINEST, String.format("Parsing FetchResult (%d bytes, %s) for %s…", fetchResult.size(), fetchResult.getMimeType(), originalSone));
+               Bucket soneBucket = fetchResult.asBucket();
+               InputStream soneInputStream = null;
+               try {
+                       soneInputStream = soneBucket.getInputStream();
+                       Sone parsedSone = soneParser.parseSone(originalSone,
+                                       soneInputStream);
+                       if (parsedSone != null) {
+                               parsedSone.setLatestEdition(requestUri.getEdition());
+                       }
+                       return parsedSone;
+               } catch (Exception e1) {
+                       logger.log(Level.WARNING, String.format("Could not parse Sone from %s!", requestUri), e1);
+               } finally {
+                       close(soneInputStream);
+                       close(soneBucket);
+               }
+               return null;
+       }
+       @Override
+       public Runnable fetchSoneWithUriAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone, sone.getRequestUri());
+                       }
+               };
+       }
+       @Override
+       public Runnable fetchSoneAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone);
+                       }
+               };
+       }
+       /** {@inheritDoc} */
+       @Override
+       protected void serviceStop() {
+               for (Sone sone : sones) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+       }
+ }
  
  package net.pterodactylus.sone.core;
  
- import static com.google.common.base.Preconditions.checkArgument;
+ import static java.lang.String.format;
+ import static java.lang.System.currentTimeMillis;
+ import static java.util.logging.Logger.getLogger;
  import static net.pterodactylus.sone.data.Album.NOT_EMPTY;
  
++import java.io.Closeable;
+ import java.io.InputStream;
  import java.io.InputStreamReader;
  import java.io.StringWriter;
  import java.nio.charset.Charset;
  import java.util.HashMap;
  import java.util.HashSet;
  import java.util.Map;
 +import java.util.Set;
+ import java.util.concurrent.atomic.AtomicInteger;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
@@@ -38,9 -43,9 +45,8 @@@ import net.pterodactylus.sone.data.Post
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.data.Sone.SoneStatus;
 -import net.pterodactylus.sone.freenet.StringBucket;
  import net.pterodactylus.sone.main.SonePlugin;
  import net.pterodactylus.util.io.Closer;
- import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.service.AbstractService;
  import net.pterodactylus.util.template.HtmlFilter;
  import net.pterodactylus.util.template.ReflectionAccessor;
@@@ -51,16 -56,15 +57,19 @@@ import net.pterodactylus.util.template.
  import net.pterodactylus.util.template.TemplateParser;
  import net.pterodactylus.util.template.XmlFilter;
  
+ import com.google.common.annotations.VisibleForTesting;
 +import com.google.common.base.Charsets;
+ import com.google.common.base.Optional;
  import com.google.common.collect.FluentIterable;
  import com.google.common.collect.Ordering;
  import com.google.common.eventbus.EventBus;
+ import com.google.common.eventbus.Subscribe;
  
 -import freenet.client.async.ManifestElement;
  import freenet.keys.FreenetURI;
 +import freenet.support.api.Bucket;
 +import freenet.support.api.ManifestElement;
 +import freenet.support.api.RandomAccessBucket;
 +import freenet.support.io.ArrayBucket;
  
  /**
   * A Sone inserter is responsible for inserting a Sone if it has changed.
@@@ -292,11 -279,11 +285,13 @@@ public class SoneInserter extends Abstr
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private class InsertInformation {
+       @VisibleForTesting
 -      class InsertInformation {
++      class InsertInformation implements Closeable {
  
-               private final Set<Bucket> buckets = new HashSet<Bucket>();
 +              /** All properties of the Sone, copied for thread safety. */
 +              private final Map<String, Object> soneProperties = new HashMap<String, Object>();
+               private final String fingerprint;
+               private final ManifestCreator manifestCreator;
  
                /**
                 * Creates a new insert information container.
                        return manifestEntries;
                }
  
-               //
-               // PRIVATE METHODS
-               //
++              @Override
++              public void close() {
++                      manifestCreator.close();
++              }
 +
-               /**
-                * Creates a new manifest element.
-                *
-                * @param name
-                *            The name of the file
-                * @param contentType
-                *            The content type of the file
-                * @param templateName
-                *            The name of the template to render
-                * @return The manifest element
-                */
-               @SuppressWarnings("synthetic-access")
-               private ManifestElement createManifestElement(String name, String contentType, String templateName) {
+       }
+       /**
+        * Creates manifest elements for an insert by rendering a template.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
 -      static class ManifestCreator {
++      static class ManifestCreator implements Closeable {
+               private final Core core;
+               private final Map<String, Object> soneProperties;
++              private final Set<Bucket> buckets = new HashSet<Bucket>();
+               ManifestCreator(Core core, Map<String, Object> soneProperties) {
+                       this.core = core;
+                       this.soneProperties = soneProperties;
+               }
+               public ManifestElement createManifestElement(String name, String contentType, String templateName) {
                        InputStreamReader templateInputStreamReader = null;
+                       InputStream templateInputStream = null;
                        Template template;
                        try {
-                               templateInputStreamReader = new InputStreamReader(getClass().getResourceAsStream(templateName), utf8Charset);
+                               templateInputStream = getClass().getResourceAsStream(templateName);
+                               templateInputStreamReader = new InputStreamReader(templateInputStream, utf8Charset);
                                template = TemplateParser.parse(templateInputStreamReader);
                        } catch (TemplateException te1) {
                                logger.log(Level.SEVERE, String.format("Could not parse template “%s”!", templateName), te1);
@@@ -23,8 -25,7 +25,7 @@@ import net.pterodactylus.util.config.At
  import net.pterodactylus.util.config.Configuration;
  import net.pterodactylus.util.config.ConfigurationException;
  import net.pterodactylus.util.config.ExtendedConfigurationBackend;
- import net.pterodactylus.util.logging.Logging;
 -import freenet.client.async.DatabaseDisabledException;
 +import freenet.client.async.PersistenceDisabledException;
  import freenet.pluginmanager.PluginRespirator;
  import freenet.pluginmanager.PluginStore;
  
index 0000000,c753740..091c93f
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,393 +1,401 @@@
 -import static freenet.client.InsertException.CANCELLED;
 -import static freenet.client.InsertException.INTERNAL_ERROR;
+ package net.pterodactylus.sone.core;
 -import net.pterodactylus.sone.freenet.StringBucket;
+ import static freenet.keys.InsertableClientSSK.createRandom;
+ import static freenet.node.RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+ import static freenet.node.RequestStarter.PREFETCH_PRIORITY_CLASS;
+ import static net.pterodactylus.sone.Matchers.delivers;
+ import static net.pterodactylus.sone.TestUtil.setFinalField;
+ import static org.hamcrest.MatcherAssert.assertThat;
+ import static org.hamcrest.Matchers.is;
+ import static org.hamcrest.Matchers.notNullValue;
+ import static org.hamcrest.Matchers.nullValue;
+ import static org.mockito.ArgumentCaptor.forClass;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.anyBoolean;
+ import static org.mockito.Matchers.anyShort;
+ import static org.mockito.Matchers.eq;
+ import static org.mockito.Mockito.doNothing;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import static org.mockito.Mockito.withSettings;
+ import java.io.IOException;
+ import java.net.MalformedURLException;
+ import java.util.HashMap;
+ import net.pterodactylus.sone.TestUtil;
+ import net.pterodactylus.sone.core.FreenetInterface.Callback;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.core.FreenetInterface.InsertToken;
+ import net.pterodactylus.sone.core.FreenetInterface.InsertTokenSupplier;
+ import net.pterodactylus.sone.core.event.ImageInsertAbortedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertFailedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertFinishedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertStartedEvent;
+ import net.pterodactylus.sone.data.Image;
+ import net.pterodactylus.sone.data.impl.ImageImpl;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.data.TemporaryImage;
 -              FetchException fetchException = new FetchException(FetchException.PERMANENT_REDIRECT, newFreenetUri);
+ import freenet.client.ClientMetadata;
+ import freenet.client.FetchException;
++import freenet.client.FetchException.FetchExceptionMode;
+ import freenet.client.FetchResult;
+ import freenet.client.HighLevelSimpleClient;
+ import freenet.client.InsertBlock;
+ import freenet.client.InsertContext;
+ import freenet.client.InsertException;
++import freenet.client.InsertException.InsertExceptionMode;
+ import freenet.client.async.ClientPutter;
+ import freenet.client.async.USKCallback;
+ import freenet.client.async.USKManager;
+ import freenet.crypt.DummyRandomSource;
+ import freenet.crypt.RandomSource;
+ import freenet.keys.FreenetURI;
+ import freenet.keys.InsertableClientSSK;
+ import freenet.keys.USK;
+ import freenet.node.Node;
+ import freenet.node.NodeClientCore;
+ import freenet.node.RequestClient;
+ import freenet.support.Base64;
+ import freenet.support.api.Bucket;
++import freenet.support.io.ArrayBucket;
++import freenet.support.io.ResumeFailedException;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ /**
+  * Unit test for {@link FreenetInterface}.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class FreenetInterfaceTest {
+       private final EventBus eventBus = mock(EventBus.class);
+       private final Node node = mock(Node.class);
+       private final NodeClientCore nodeClientCore = mock(NodeClientCore.class);
+       private final HighLevelSimpleClient highLevelSimpleClient = mock(HighLevelSimpleClient.class, withSettings().extraInterfaces(RequestClient.class));
+       private final RandomSource randomSource = new DummyRandomSource();
+       private final USKManager uskManager = mock(USKManager.class);
+       private FreenetInterface freenetInterface;
+       private final Sone sone = mock(Sone.class);
+       private final ArgumentCaptor<USKCallback> callbackCaptor = forClass(USKCallback.class);
+       private final Image image = mock(Image.class);
+       private InsertToken insertToken;
++      private final Bucket bucket = mock(Bucket.class);
+       @Before
+       public void setupFreenetInterface() {
+               when(nodeClientCore.makeClient(anyShort(), anyBoolean(), anyBoolean())).thenReturn(highLevelSimpleClient);
+               setFinalField(node, "clientCore", nodeClientCore);
+               setFinalField(node, "random", randomSource);
+               setFinalField(nodeClientCore, "uskManager", uskManager);
+               freenetInterface = new FreenetInterface(eventBus, node);
+               insertToken = freenetInterface.new InsertToken(image);
++              insertToken.setBucket(bucket);
+       }
+       @Before
+       public void setupSone() {
+               InsertableClientSSK insertSsk = createRandom(randomSource, "test-0");
+               when(sone.getId()).thenReturn(Base64.encode(insertSsk.getURI().getRoutingKey()));
+               when(sone.getRequestUri()).thenReturn(insertSsk.getURI().uskForSSK());
+       }
+       @Before
+       public void setupCallbackCaptorAndUskManager() {
+               doNothing().when(uskManager).subscribe(any(USK.class), callbackCaptor.capture(), anyBoolean(), any(RequestClient.class));
+       }
+       @Test
+       public void canFetchUri() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
+               when(highLevelSimpleClient.fetch(freenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, notNullValue());
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+       }
+       @Test
+       public void fetchFollowsRedirect() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
+               FreenetURI newFreenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
 -              FetchException fetchException = new FetchException(FetchException.ALL_DATA_NOT_FOUND);
++              FetchException fetchException = new FetchException(FetchExceptionMode.PERMANENT_REDIRECT, newFreenetUri);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               when(highLevelSimpleClient.fetch(newFreenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(newFreenetUri));
+       }
+       @Test
+       public void fetchReturnsNullOnFetchExceptions() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
 -              Bucket bucket = new StringBucket("Some Data.");
++              FetchException fetchException = new FetchException(FetchExceptionMode.ALL_DATA_NOT_FOUND);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, nullValue());
+       }
+       private FetchResult createFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq(false), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenReturn(clientPutter);
++              Bucket bucket = new ArrayBucket("Some Data.".getBytes());
+               return new FetchResult(clientMetadata, bucket);
+       }
+       @Test
+       public void insertingAnImage() throws SoneException, InsertException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
 -              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq(false), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenThrow(InsertException.class);
++              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenReturn(clientPutter);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+               assertThat(insertBlockCaptor.getValue().getData().getInputStream(), delivers(new byte[] { 1, 2, 3, 4 }));
+               assertThat(TestUtil.<ClientPutter>getPrivateField(insertToken, "clientPutter"), is(clientPutter));
+               verify(eventBus).post(any(ImageInsertStartedEvent.class));
+       }
+       @Test(expected = SoneInsertException.class)
+       public void insertExceptionCausesASoneException() throws InsertException, SoneException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
 -              callbackCaptor.getValue().onFoundEdition(3, key, null, null, false, (short) 0, null, true, true);
++              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenThrow(InsertException.class);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+       }
+       @Test
+       public void insertingADirectory() throws InsertException, SoneException {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               HashMap<String, Object> manifestEntries = new HashMap<String, Object>();
+               String defaultFile = "index.html";
+               FreenetURI resultingUri = mock(FreenetURI.class);
+               when(highLevelSimpleClient.insertManifest(eq(freenetUri), eq(manifestEntries), eq(defaultFile))).thenReturn(resultingUri);
+               assertThat(freenetInterface.insertDirectory(freenetUri, manifestEntries, defaultFile), is(resultingUri));
+       }
+       @Test(expected = SoneException.class)
+       public void insertExceptionIsForwardedAsSoneException() throws InsertException, SoneException {
+               when(highLevelSimpleClient.insertManifest(any(FreenetURI.class), any(HashMap.class), any(String.class))).thenThrow(InsertException.class);
+               freenetInterface.insertDirectory(null, null, null);
+       }
+       @Test
+       public void soneWithWrongRequestUriWillNotBeSubscribed() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(new FreenetURI("KSK@GPLv3.txt"), null);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAUsk() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringANonUskKeyWillNotBeSubscribed() throws MalformedURLException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringAnActiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerActiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(true), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAnInactiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerPassiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(false), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAnActiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerActiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringAnInactiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerPassiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void unregisteringANotRegisteredUskDoesNothing() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringARegisteredUsk() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetURI, callback);
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringANotRegisteredSoneDoesNothing() {
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringARegisteredSoneUnregistersTheSone()
+       throws MalformedURLException {
+               freenetInterface.registerActiveUsk(sone.getRequestUri(), mock(USKCallback.class));
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringASoneWithAWrongRequestKeyWillNotUnsubscribe() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(sone.getRequestUri(), null);
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void callbackForNormalUskUsesDifferentPriorities() {
+               Callback callback = mock(Callback.class);
+               FreenetURI soneUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(soneUri, callback);
+               assertThat(callbackCaptor.getValue().getPollingPriorityNormal(), is(PREFETCH_PRIORITY_CLASS));
+               assertThat(callbackCaptor.getValue().getPollingPriorityProgress(), is(INTERACTIVE_PRIORITY_CLASS));
+       }
+       @Test
+       public void callbackForNormalUskForwardsImportantParameters() throws MalformedURLException {
+               Callback callback = mock(Callback.class);
+               FreenetURI uri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(uri, callback);
+               USK key = mock(USK.class);
+               when(key.getURI()).thenReturn(uri);
 -              insertToken.onFailure(null, null, null);
++              callbackCaptor.getValue().onFoundEdition(3, key, null, false, (short) 0, null, true, true);
+               verify(callback).editionFound(eq(uri), eq(3L), eq(true), eq(true));
+       }
+       @Test
+       public void fetchedRetainsUriAndFetchResult() {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               FetchResult fetchResult = mock(FetchResult.class);
+               Fetched fetched = new Fetched(freenetUri, fetchResult);
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+       }
+       @Test
+       public void cancellingAnInsertWillFireImageInsertAbortedEvent() {
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               insertToken.setClientPutter(clientPutter);
+               ArgumentCaptor<ImageInsertStartedEvent> imageInsertStartedEvent = forClass(ImageInsertStartedEvent.class);
+               verify(eventBus).post(imageInsertStartedEvent.capture());
+               assertThat(imageInsertStartedEvent.getValue().image(), is(image));
+               insertToken.cancel();
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus, times(2)).post(imageInsertAbortedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+       @Test
+       public void failureWithoutExceptionSendsFailedEvent() {
 -              InsertException insertException = new InsertException(INTERNAL_ERROR, "Internal error", null);
 -              insertToken.onFailure(insertException, null, null);
++              insertToken.onFailure(null, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), nullValue());
+       }
+       @Test
+       public void failureSendsFailedEventWithException() {
 -              InsertException insertException = new InsertException(CANCELLED, null);
 -              insertToken.onFailure(insertException, null, null);
++              InsertException insertException = new InsertException(InsertExceptionMode.INTERNAL_ERROR, "Internal error", null);
++              insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), is((Throwable) insertException));
+       }
+       @Test
+       public void failureBecauseCancelledByUserSendsAbortedEvent() {
 -      public void ignoredMethodsDoNotThrowExceptions() {
 -              insertToken.onMajorProgress(null);
 -              insertToken.onFetchable(null, null);
 -              insertToken.onGeneratedMetadata(null, null, null);
++              InsertException insertException = new InsertException(InsertExceptionMode.CANCELLED, null);
++              insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus).post(imageInsertAbortedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+       @Test
 -              insertToken.onGeneratedURI(generatedUri, null, null);
 -              insertToken.onSuccess(null, null);
++      public void ignoredMethodsDoNotThrowExceptions() throws ResumeFailedException {
++              insertToken.onResume(null);
++              insertToken.onFetchable(null);
++              insertToken.onGeneratedMetadata(null, null);
+       }
+       @Test
+       public void generatedUriIsPostedOnSuccess() {
+               FreenetURI generatedUri = mock(FreenetURI.class);
++              insertToken.onGeneratedURI(generatedUri, null);
++              insertToken.onSuccess(null);
+               ArgumentCaptor<ImageInsertFinishedEvent> imageInsertFinishedEvent = forClass(ImageInsertFinishedEvent.class);
+               verify(eventBus).post(imageInsertFinishedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFinishedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFinishedEvent.getValue().resultingUri(), is(generatedUri));
+       }
+       @Test
+       public void insertTokenSupplierSuppliesInsertTokens() {
+               InsertTokenSupplier insertTokenSupplier = freenetInterface.new InsertTokenSupplier();
+               assertThat(insertTokenSupplier.apply(image), notNullValue());
+       }
+ }
index 0000000,e0ff3a5..552746e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,291 +1,289 @@@
 -import static org.hamcrest.Matchers.containsInAnyOrder;
+ package net.pterodactylus.sone.core;
+ import static com.google.common.base.Optional.of;
+ import static com.google.common.io.ByteStreams.toByteArray;
+ import static com.google.common.util.concurrent.MoreExecutors.sameThreadExecutor;
+ import static java.lang.System.currentTimeMillis;
+ import static org.hamcrest.MatcherAssert.assertThat;
 -import net.pterodactylus.sone.core.SoneInserter.InsertInformation;
+ import static org.hamcrest.Matchers.containsString;
+ import static org.hamcrest.Matchers.instanceOf;
+ import static org.hamcrest.Matchers.is;
+ import static org.hamcrest.Matchers.nullValue;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.anyString;
+ import static org.mockito.Matchers.argThat;
+ import static org.mockito.Matchers.eq;
+ import static org.mockito.Mockito.doAnswer;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import java.io.IOException;
+ import java.util.HashMap;
+ import java.util.Map;
 -import freenet.client.async.ManifestElement;
+ import net.pterodactylus.sone.core.SoneInserter.ManifestCreator;
+ import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
+ import net.pterodactylus.sone.core.event.SoneEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertAbortedEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertedEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertingEvent;
+ import net.pterodactylus.sone.data.Album;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.main.SonePlugin;
+ import freenet.keys.FreenetURI;
++import freenet.support.api.ManifestElement;
+ import com.google.common.base.Charsets;
+ import com.google.common.base.Optional;
+ import com.google.common.eventbus.AsyncEventBus;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ import org.mockito.invocation.InvocationOnMock;
+ import org.mockito.stubbing.Answer;
+ /**
+  * Unit test for {@link SoneInserter} and its subclasses.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class SoneInserterTest {
+       private final Core core = mock(Core.class);
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       @Before
+       public void setupCore() {
+               UpdateChecker updateChecker = mock(UpdateChecker.class);
+               when(core.getUpdateChecker()).thenReturn(updateChecker);
+               when(core.getSone(anyString())).thenReturn(Optional.<Sone>absent());
+       }
+       @Test
+       public void insertionDelayIsForwardedToSoneInserter() {
+               EventBus eventBus = new AsyncEventBus(sameThreadExecutor());
+               eventBus.register(new SoneInserter(core, eventBus, freenetInterface, "SoneId"));
+               eventBus.post(new InsertionDelayChangedEvent(15));
+               assertThat(SoneInserter.getInsertionDelay().get(), is(15));
+       }
+       private Sone createSone(FreenetURI insertUri, String fingerprint) {
+               Sone sone = mock(Sone.class);
+               when(sone.getInsertUri()).thenReturn(insertUri);
+               when(sone.getFingerprint()).thenReturn(fingerprint);
+               when(sone.getRootAlbum()).thenReturn(mock(Album.class));
+               when(core.getSone(anyString())).thenReturn(of(sone));
+               return sone;
+       }
+       @Test
+       public void isModifiedIsTrueIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isModified()).thenReturn(true);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(true));
+       }
+       @Test
+       public void isModifiedIsFalseIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(false));
+       }
+       @Test
+       public void lastFingerprintIsStoredCorrectly() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.setLastInsertFingerprint("last-fingerprint");
+               assertThat(soneInserter.getLastInsertFingerprint(), is("last-fingerprint"));
+       }
+       @Test
+       public void soneInserterStopsWhenItShould() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.stop();
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void soneInserterInsertsASoneIfItIsEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenReturn(finalUri);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return null;
+                       }
+               }).when(core).touchConfiguration();
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+       }
+       @Test
+       public void soneInserterBailsOutIfItIsStoppedWhileInserting() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return finalUri;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+       @Test
+       public void soneInserterDoesNotInsertSoneIfItIsNotEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       Thread.sleep(500);
+                               } catch (InterruptedException ie1) {
+                                       throw new RuntimeException(ie1);
+                               }
+                               soneInserter.stop();
+                       }
+               }).start();
+               soneInserter.serviceRun();
+               verify(freenetInterface, never()).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, never()).post(argThat(org.hamcrest.Matchers.any(SoneEvent.class)));
+       }
+       @Test
+       public void soneInserterPostsAbortedEventIfAnExceptionOccurs() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               final SoneException soneException = new SoneException(new Exception());
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               throw soneException;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertAbortedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+       @Test
+       public void soneInserterExitsIfSoneIsUnknown() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(core.getSone("SoneId")).thenReturn(Optional.<Sone>absent());
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void soneInserterCatchesExceptionAndContinues() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               Answer<Optional<Sone>> stopInserterAndThrowException =
+                               new Answer<Optional<Sone>>() {
+                                       @Override
+                                       public Optional<Sone> answer(
+                                                       InvocationOnMock invocation) {
+                                               soneInserter.stop();
+                                               throw new NullPointerException();
+                                       }
+                               };
+               when(soneModificationDetector.isEligibleForInsert()).thenAnswer(
+                               stopInserterAndThrowException);
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void templateIsRenderedCorrectlyForManifestElement()
+       throws IOException {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               soneProperties.put("id", "SoneId");
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               long now = currentTimeMillis();
+               when(core.getStartupTime()).thenReturn(now);
+               ManifestElement manifestElement = manifestCreator.createManifestElement("test.txt", "plain/text; charset=utf-8", "sone-inserter-manifest.txt");
+               assertThat(manifestElement.getName(), is("test.txt"));
+               assertThat(manifestElement.getMimeTypeOverride(), is("plain/text; charset=utf-8"));
+               String templateContent = new String(toByteArray(manifestElement.getData().getInputStream()), Charsets.UTF_8);
+               assertThat(templateContent, containsString("Sone Version: " + SonePlugin.VERSION.toString() + "\n"));
+               assertThat(templateContent, containsString("Core Startup: " + now + "\n"));
+               assertThat(templateContent, containsString("Sone ID: " + "SoneId" + "\n"));
+       }
+       @Test
+       public void invalidTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-invalid-manifest.txt"),
+                               nullValue());
+       }
+       @Test
+       public void errorWhileRenderingTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               when(core.toString()).thenThrow(NullPointerException.class);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-faulty-manifest.txt"),
+                               nullValue());
+       }
+ }
index 0000000,b3f8c08..a5e3b2a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,233 +1,233 @@@
 -import net.pterodactylus.sone.freenet.StringBucket;
+ package net.pterodactylus.sone.core;
+ import static java.lang.Long.MAX_VALUE;
+ import static net.pterodactylus.sone.main.SonePlugin.VERSION;
+ import static org.hamcrest.MatcherAssert.assertThat;
+ import static org.hamcrest.Matchers.instanceOf;
+ import static org.hamcrest.Matchers.is;
+ import static org.mockito.ArgumentCaptor.forClass;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.argThat;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import net.pterodactylus.sone.core.FreenetInterface.Callback;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.core.event.UpdateFoundEvent;
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
+ import net.pterodactylus.util.version.Version;
+ import freenet.client.ClientMetadata;
+ import freenet.client.FetchResult;
+ import freenet.keys.FreenetURI;
+ import freenet.support.api.Bucket;
++import freenet.support.io.ArrayBucket;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ import org.mockito.invocation.InvocationOnMock;
+ import org.mockito.stubbing.Answer;
+ /**
+  * Unit test for {@link UpdateChecker}.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class UpdateCheckerTest {
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       private final UpdateChecker updateChecker = new UpdateChecker(eventBus, freenetInterface);
+       @Before
+       public void startUpdateChecker() {
+               updateChecker.start();
+       }
+       @Test
+       public void newUpdateCheckerDoesNotHaveALatestVersion() {
+               assertThat(updateChecker.hasLatestVersion(), is(false));
+               assertThat(updateChecker.getLatestVersion(), is(VERSION));
+       }
+       @Test
+       public void startingAnUpdateCheckerRegisterAUsk() {
+               verify(freenetInterface).registerUsk(any(FreenetURI.class), any(Callback.class));
+       }
+       @Test
+       public void stoppingAnUpdateCheckerUnregistersAUsk() {
+               updateChecker.stop();
+               verify(freenetInterface).unregisterUsk(any(FreenetURI.class));
+       }
+       @Test
+       public void callbackDoesNotDownloadIfNewEditionIsNotFound() {
+               setupCallbackWithEdition(MAX_VALUE, false, false);
+               verify(freenetInterface, never()).fetchUri(any(FreenetURI.class));
+               verify(eventBus, never()).post(argThat(instanceOf(UpdateFoundEvent.class)));
+       }
+       private void setupCallbackWithEdition(long edition, boolean newKnownGood, boolean newSlot) {
+               ArgumentCaptor<FreenetURI> uri = forClass(FreenetURI.class);
+               ArgumentCaptor<Callback> callback = forClass(Callback.class);
+               verify(freenetInterface).registerUsk(uri.capture(), callback.capture());
+               callback.getValue().editionFound(uri.getValue(), edition, newKnownGood, newSlot);
+       }
+       @Test
+       public void callbackStartsIfNewEditionIsFound() {
+               setupFetchResult(createFutureFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               ArgumentCaptor<UpdateFoundEvent> updateFoundEvent = forClass(UpdateFoundEvent.class);
+               verify(eventBus, times(1)).post(updateFoundEvent.capture());
+               assertThat(updateFoundEvent.getValue().version(), is(new Version(99, 0, 0)));
+               assertThat(updateFoundEvent.getValue().releaseTime(), is(11865368297000L));
+               assertThat(updateChecker.getLatestVersion(), is(new Version(99, 0, 0)));
+               assertThat(updateChecker.getLatestVersionDate(), is(11865368297000L));
+               assertThat(updateChecker.hasLatestVersion(), is(true));
+       }
+       private FetchResult createFutureFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 11865368297000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 99.0.0\n" +
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++                              "CurrentVersion/ReleaseTime: 11865368297000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void callbackDoesNotStartIfNoNewEditionIsFound() {
+               setupFetchResult(createPastFetchResult());
+               setupCallbackWithEdition(updateChecker.getLatestEdition(), true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private void setupFetchResult(final FetchResult pastFetchResult) {
+               when(freenetInterface.fetchUri(any(FreenetURI.class))).thenAnswer(new Answer<Fetched>() {
+                       @Override
+                       public Fetched answer(InvocationOnMock invocation) throws Throwable {
+                               FreenetURI freenetUri = (FreenetURI) invocation.getArguments()[0];
+                               return new Fetched(freenetUri, pastFetchResult);
+                       }
+               });
+       }
+       private FetchResult createPastFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 1289417883000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
 -              Bucket fetched = new StringBucket("Some other data.");
++                              "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidUpdateFileDoesNotStartCallback() {
+               setupFetchResult(createInvalidFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              Bucket fetched = new StringBucket("Some other data.") {
++              Bucket fetched = new ArrayBucket("Some other data.".getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void nonExistingPropertiesWillNotCauseUpdateToBeFound() {
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private void verifyNoUpdateFoundEventIsFired() {
+               verify(eventBus, never()).post(any(UpdateFoundEvent.class));
+       }
+       private void verifyAFreenetUriIsFetched() {
+               verify(freenetInterface).fetchUri(any(FreenetURI.class));
+       }
+       @Test
+       public void brokenBucketDoesNotCauseUpdateToBeFound() {
+               setupFetchResult(createBrokenBucketFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createBrokenBucketFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++              Bucket fetched = new ArrayBucket("Some other data.".getBytes()) {
+                       @Override
+                       public InputStream getInputStream() {
+                               try {
+                                       return when(mock(InputStream.class).read()).thenThrow(IOException.class).getMock();
+                               } catch (IOException ioe1) {
+                                       /* won’t throw here. */
+                                       return null;
+                               }
+                       }
+               };
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidTimeDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: invalid");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
 -                              "CurrentVersion/Version: 0.2\n");
++                              "CurrentVersion/ReleaseTime: invalid").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidPropertiesDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createMissingTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createMissingTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
++                              "CurrentVersion/Version: 0.2\n").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidVersionDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidVersionFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidVersionFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 1289417883000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: foo\n" +
++                              "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+ }