🔊 Log element loader’s decisions and results
[Sone.git] / src / test / kotlin / net / pterodactylus / sone / core / DefaultElementLoaderTest.kt
1 package net.pterodactylus.sone.core
2
3 import com.google.common.base.Ticker
4 import freenet.keys.FreenetURI
5 import net.pterodactylus.sone.core.FreenetInterface.BackgroundFetchCallback
6 import net.pterodactylus.sone.test.*
7 import org.hamcrest.Description
8 import org.hamcrest.Matcher
9 import org.hamcrest.MatcherAssert.assertThat
10 import org.hamcrest.Matchers.allOf
11 import org.hamcrest.Matchers.contains
12 import org.hamcrest.Matchers.containsString
13 import org.hamcrest.Matchers.equalTo
14 import org.hamcrest.Matchers.hasEntry
15 import org.hamcrest.Matchers.not
16 import org.hamcrest.TypeSafeDiagnosingMatcher
17 import org.junit.Rule
18 import org.junit.Test
19 import java.io.ByteArrayOutputStream
20 import java.util.concurrent.TimeUnit.MINUTES
21 import java.util.concurrent.atomic.AtomicReference
22 import java.util.logging.Handler
23 import java.util.logging.Level.ALL
24 import java.util.logging.LogRecord
25 import java.util.logging.Logger
26 import kotlin.math.min
27
28 /**
29  * Unit test for [DefaultElementLoaderTest].
30  */
31 class DefaultElementLoaderTest {
32
33         @Test
34         fun `image loader starts request for link that is not known`() {
35                 runWithCallback(IMAGE_ID) { _, _, _, fetchedUris ->
36                         assertThat(fetchedUris, contains(freenetURI))
37                 }
38         }
39
40         @Test
41         fun `element loader only starts request once`() {
42                 runWithCallback(IMAGE_ID) { elementLoader, _, _, fetchedUris ->
43                         elementLoader.loadElement(IMAGE_ID)
44                         assertThat(fetchedUris, contains(freenetURI))
45                 }
46         }
47
48         @Test
49         fun `element loader returns loading element on first call`() {
50                 runWithCallback(IMAGE_ID) { _, linkedElement, _, _ ->
51                         assertThat(linkedElement.loading, equalTo(true))
52                 }
53         }
54
55         @Test
56         fun `element loader does not cancel on image mime type with 2 mib size`() {
57                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
58                         assertThat(callback.shouldCancel(freenetURI, "image/png", sizeOkay), equalTo(false))
59                         assertThat(loggedRecords.map(LogRecord::getMessage), not(contains(containsString("Canceling download"))))
60                 }
61         }
62
63         @Test
64         fun `element loader does cancel on image mime type with more than 2 mib size`() {
65                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
66                         assertThat(callback.shouldCancel(freenetURI, "image/png", sizeNotOkay), equalTo(true))
67                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(containsString("Canceling download")))
68                 }
69         }
70
71         @Test
72         fun `element loader does cancel on audio mime type`() {
73                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
74                         assertThat(callback.shouldCancel(freenetURI, "audio/mpeg", sizeOkay), equalTo(true))
75                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(containsString("Canceling download")))
76                 }
77         }
78
79         @Test
80         fun `element loader does cancel on video mime type`() {
81                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
82                         assertThat(callback.shouldCancel(freenetURI, "video/mkv", sizeOkay), equalTo(true))
83                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(containsString("Canceling download")))
84                 }
85         }
86
87         @Test
88         fun `element loader does cancel on text mime type`() {
89                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
90                         assertThat(callback.shouldCancel(freenetURI, "text/plain", sizeOkay), equalTo(true))
91                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(containsString("Canceling download")))
92                 }
93         }
94
95         @Test
96         fun `element loader does not cancel on text html mime type`() {
97                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
98                         assertThat(callback.shouldCancel(freenetURI, "text/html", sizeOkay), equalTo(false))
99                         assertThat(loggedRecords.map(LogRecord::getMessage), not(contains(containsString("Canceling download"))))
100                 }
101         }
102
103         @Test
104         fun `image loader can load image`() {
105                 runWithCallback(decomposedKey) { elementLoader, _, callback, _ ->
106                         callback.loaded(FreenetURI(normalizedKey), "image/png", read("/static/images/unknown-image-0.png"))
107                         val linkedElement = elementLoader.loadElement(decomposedKey)
108                         assertThat(linkedElement, isLinkedElement(equalTo(normalizedKey), allOf(
109                                 hasEntry("type", "image"), hasEntry("size", 2451), hasEntry("sizeHuman", "2 KiB"),
110                         )))
111                 }
112         }
113
114         @Test
115         fun `element loader logs information about downloaded image`() {
116                 runWithCallback(decomposedKey) { _, _, callback, _ ->
117                         callback.loaded(FreenetURI(normalizedKey), "image/png", read("/static/images/unknown-image-0.png"))
118                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(allOf(
119                                 containsString(normalizedKey), containsString("2451")
120                         )))
121                 }
122         }
123
124         @Test
125         fun `element loader can extract description from description header`() {
126                 runWithCallback(textKey) { elementLoader, _, callback, _ ->
127                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader.html"))
128                         val linkedElement = elementLoader.loadElement(textKey)
129                         assertThat(linkedElement, isLinkedElement(equalTo(textKey), allOf(
130                                 hasEntry("type", "html"), hasEntry("size", 266), hasEntry("sizeHuman", "266 B"),
131                                 hasEntry("title", "Some Nice Page Title"),
132                                 hasEntry("description", "This is an example of a very nice freesite.")
133                         )))
134                 }
135         }
136
137         @Test
138         fun `element loader logs information from downloaded freesite`() {
139                 runWithCallback(textKey) { _, _, callback, _ ->
140                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader.html"))
141                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(allOf(
142                                 containsString(textKey), containsString("Some Nice Page Title"),
143                                 containsString("This is an example of a very nice freesite.")
144                         )))
145                 }
146         }
147
148         @Test
149         fun `element loader can extract description from first non-heading paragraph`() {
150                 runWithCallback(textKey) { elementLoader, _, callback, _ ->
151                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader2.html"))
152                         val linkedElement = elementLoader.loadElement(textKey)
153                         assertThat(linkedElement, isLinkedElement(equalTo(textKey), allOf(
154                                 hasEntry("type", "html"), hasEntry("size", 185), hasEntry("sizeHuman", "185 B"),
155                                 hasEntry("title", "Some Nice Page Title"),
156                                 hasEntry("description", "This is the first paragraph of the very nice freesite.")
157                         )))
158                 }
159         }
160
161         @Test
162         fun `element loader can extract description if html is more complicated`() {
163                 runWithCallback(textKey) { elementLoader, _, callback, _ ->
164                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader3.html"))
165                         val linkedElement = elementLoader.loadElement(textKey)
166                         assertThat(linkedElement, isLinkedElement(equalTo(textKey), allOf(
167                                 hasEntry("type", "html"), hasEntry("size", 204), hasEntry("sizeHuman", "204 B"),
168                                 hasEntry("title", "Some Nice Page Title"),
169                                 hasEntry("description", "This is the first paragraph of the very nice freesite.")
170                         )))
171                 }
172         }
173
174         @Test
175         fun `element loader can not extract title if it is missing`() {
176                 runWithCallback(textKey) { elementLoader, _, callback, _ ->
177                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader4.html"))
178                         val linkedElement = elementLoader.loadElement(textKey)
179                         assertThat(linkedElement, isLinkedElement(equalTo(textKey), allOf(
180                                 hasEntry("type", "html"), hasEntry("size", 229), hasEntry("sizeHuman", "229 B"), hasEntry("title", null),
181                                 hasEntry("description", "This is an example of a very nice freesite.")
182                         )))
183                 }
184         }
185
186         @Test
187         fun `element loader can extract first paragraph from real-world example`() {
188                 runWithCallback(textKey) { elementLoader, _, callback, _ ->
189                         callback.loaded(FreenetURI(textKey), "text/html; charset=UTF-8", read("element-loader5.html"))
190                         val linkedElement = elementLoader.loadElement(textKey)
191                         assertThat(linkedElement, isLinkedElement(equalTo(textKey), allOf(
192                                 hasEntry("type", "html"), hasEntry("title", "Some Nice Page Title"),
193                                 hasEntry("description", "This is the first paragraph of the very nice freesite.")
194                         )))
195                 }
196         }
197
198         @Test
199         fun `image is not loaded again after it failed`() {
200                 runWithCallback(IMAGE_ID) { elementLoader, _, callback, _ ->
201                         elementLoader.loadElement(IMAGE_ID)
202                         callback.failed(freenetURI)
203                         assertThat(elementLoader.loadElement(IMAGE_ID).failed, equalTo(true))
204                 }
205         }
206
207         @Test
208         fun `element loading failure is logged`() {
209                 runWithCallback(IMAGE_ID) { _, _, callback, _ ->
210                         callback.failed(freenetURI)
211                         assertThat(loggedRecords.map(LogRecord::getMessage), contains(containsString("Download failed")))
212                 }
213         }
214
215         @Test
216         fun `image is loaded again after failure cache is expired`() {
217                 runWithCallback(IMAGE_ID, createTicker(1, MINUTES.toNanos(31))) { elementLoader, _, callback, _ ->
218                         elementLoader.loadElement(IMAGE_ID)
219                         callback.failed(freenetURI)
220                         val linkedElement = elementLoader.loadElement(IMAGE_ID)
221                         assertThat(linkedElement.failed, equalTo(false))
222                         assertThat(linkedElement.loading, equalTo(true))
223                 }
224         }
225
226         private fun read(resource: String): ByteArray =
227                 javaClass.getResourceAsStream(resource)?.use { input ->
228                         ByteArrayOutputStream().use {
229                                 input.copyTo(it)
230                                 it
231                         }.toByteArray()
232                 } ?: ByteArray(0)
233
234         @get:Rule
235         val silencedLoggin = silencedLogging()
236
237         private val loggedRecords = mutableListOf<LogRecord>()
238
239         init {
240                 Logger.getLogger(DefaultElementLoader::class.qualifiedName)
241                         .apply { level = ALL }
242                         .apply {
243                                 addHandler(object : Handler() {
244                                         override fun publish(record: LogRecord) {
245                                                 loggedRecords += record
246                                         }
247
248                                         override fun flush() = Unit
249                                         override fun close() = Unit
250                                 })
251                         }
252         }
253
254 }
255
256 private fun runWithCallback(requestUri: String, ticker: Ticker = createTicker(), callbackAction: (elementLoader: ElementLoader, linkedElement: LinkedElement, callback: BackgroundFetchCallback, fetchedUris: List<FreenetURI>) -> Unit) {
257         val fetchedUris = mutableListOf<FreenetURI>()
258         val callback = AtomicReference<BackgroundFetchCallback>()
259         val freenetInterface = overrideStartFetch { uri, backgroundFetchCallback ->
260                 fetchedUris += uri
261                 callback.set(backgroundFetchCallback)
262         }
263         val elementLoader = DefaultElementLoader(freenetInterface, ticker)
264         val linkedElement = elementLoader.loadElement(requestUri)
265         callbackAction(elementLoader, linkedElement, callback.get(), fetchedUris)
266 }
267
268 private fun overrideStartFetch(action: (FreenetURI, BackgroundFetchCallback) -> Unit) = object : FreenetInterface(null, null, null, null, null, dummyHighLevelSimpleClientCreator) {
269         override fun startFetch(uri: FreenetURI, backgroundFetchCallback: BackgroundFetchCallback) {
270                 action(uri, backgroundFetchCallback)
271         }
272 }
273
274 private fun createTicker(vararg times: Long = LongArray(1) { 1 }) = object : Ticker() {
275         private var counter = 0
276         override fun read() =
277                 times[min(times.size - 1, counter)]
278                         .also { counter++ }
279 }
280
281 private fun isLinkedElement(link: Matcher<String> = everything(), properties: Matcher<Map<String, Any?>> = everything(), failed: Matcher<Boolean> = everything(), loading: Matcher<Boolean> = everything()) = object : TypeSafeDiagnosingMatcher<LinkedElement>() {
282         override fun matchesSafely(item: LinkedElement, mismatchDescription: Description) =
283                 handleMatcher(link, item.link, mismatchDescription) &&
284                                 handleMatcher(properties, item.properties, mismatchDescription) &&
285                                 handleMatcher(failed, item.failed, mismatchDescription) &&
286                                 handleMatcher(loading, item.loading, mismatchDescription)
287
288         override fun describeTo(description: Description) {
289                 description.appendText("is linked element for key matching ").appendValue(link)
290                         .appendText(", properties matching ").appendValue(properties)
291                         .appendText(", failed matching ").appendValue(failed)
292                         .appendText(", loading matching ").appendValue(loading)
293         }
294 }
295
296 private const val IMAGE_ID = "KSK@gpl.png"
297 private val freenetURI = FreenetURI(IMAGE_ID)
298 private const val decomposedKey = "CHK@DCiVgTWW9nnWHJc9EVwtFJ6jAfBSVyy~rgiPvhUKbS4,mNY85V0x7dYcv7SnEYo1PCC6y2wNWMDNt-y9UWQx9fI,AAMC--8/fru%CC%88hstu%CC%88ck.jpg"
299 private const val normalizedKey = "CHK@DCiVgTWW9nnWHJc9EVwtFJ6jAfBSVyy~rgiPvhUKbS4,mNY85V0x7dYcv7SnEYo1PCC6y2wNWMDNt-y9UWQx9fI,AAMC--8/frühstück.jpg"
300 private const val textKey = "KSK@gpl.html"
301 private const val sizeOkay = 2097152L
302 private const val sizeNotOkay = sizeOkay + 1