test(simkl): cover local scrobble reconciliation

This commit is contained in:
tapframe 2026-07-28 12:37:37 +05:30
parent 2ebaf3b5a9
commit 247afcbf00
4 changed files with 297 additions and 8 deletions

View file

@ -227,8 +227,8 @@ class SimklAnimeWatchedResolutionTest {
val resolved = reference.resolveAnimeEpisodeForSimkl()
assertEquals(42203L, resolved.ids.mal)
assertEquals("tt2560140", resolved.ids.imdb)
assertEquals(39687L, resolved.ids.simkl)
assertNull(resolved.ids.imdb)
assertNull(resolved.ids.simkl)
assertNull(resolved.episode?.season)
assertEquals(7, resolved.episode?.number)
}
@ -250,7 +250,7 @@ class SimklAnimeWatchedResolutionTest {
val resolved = reference.resolveAnimeEpisodeForSimkl()
assertEquals(12268L, resolved.ids.kitsu)
assertEquals("tt5311514", resolved.ids.imdb)
assertNull(resolved.ids.imdb)
assertNull(resolved.episode?.season)
assertEquals(3, resolved.episode?.number)
}

View file

@ -153,13 +153,16 @@ class SimklMutationRepositoryTest {
}
@Test
fun `service reports partial not found and treats duplicate stop as committed`() = runBlocking {
fun `service reports partial not found and returns duplicate stop as scrobbled`() = runBlocking {
val engine = RecordingEngine(
response(
status = 201,
body = """{"added":{"movies":[{"to":"completed"}]},"not_found":{"movies":[{"title":"Missing"}],"shows":[]}}""",
),
response(status = 409),
response(
status = 409,
body = """{"watched_at":"2026-05-14T23:46:29Z","expires_at":"2026-05-15T00:46:29Z"}""",
),
)
var now = 0L
var committed = 0
@ -179,7 +182,7 @@ class SimklMutationRepositoryTest {
items = listOf(movie(), movie().copy(title = "Missing", ids = TrackingExternalIds())),
destination = TrackingListStatus.PLAN_TO_WATCH,
)
service.scrobble(
val scrobbleResult = service.scrobble(
action = TrackingScrobbleAction.STOP,
event = TrackingScrobbleEvent(movie(), progressPercent = 90.0),
)
@ -189,7 +192,9 @@ class SimklMutationRepositoryTest {
assertEquals(listOf(TrackingListStatus.COMPLETED), result.resolvedListStatuses)
assertFalse(result.isComplete)
assertEquals(listOf("/sync/add-to-list", "/scrobble/stop"), engine.paths)
assertEquals(2, committed)
assertEquals(SimklScrobbleOutcome.SCROBBLE, scrobbleResult.outcome)
assertEquals("2026-05-14T23:46:29Z", scrobbleResult.watchedAt)
assertEquals(1, committed)
}
@Test
@ -268,6 +273,72 @@ class SimklMutationRepositoryTest {
assertEquals(listOf("/scrobble/pause"), engine.paths)
}
@Test
fun `successful pause returns local reconciliation data without invalidating sync`() = runBlocking {
val engine = RecordingEngine(
response(
status = 201,
body = """{"id":42,"action":"pause","progress":45,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""",
),
)
var committed = 0
val service = SimklMutationService(
client = SimklApiClient(
engine = engine,
accessToken = { "token" },
onUnauthorized = {},
nowEpochMs = { 0L },
sleep = {},
retryJitterMs = { 0L },
),
onMutationCommitted = { committed += 1 },
)
val result = service.scrobble(
action = TrackingScrobbleAction.PAUSE,
event = TrackingScrobbleEvent(movie(), progressPercent = 45.0),
)
assertEquals(listOf("/scrobble/pause"), engine.paths)
assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome)
assertEquals(42L, result.playbackId)
assertEquals(45.0, result.progress)
assertEquals(0, committed)
}
@Test
fun `low progress stop returns a paused playback without invalidating sync`() = runBlocking {
val engine = RecordingEngine(
response(
status = 201,
body = """{"id":42,"action":"pause","progress":30,"movie":{"title":"Terminator 3: Rise of the Machines","year":2003,"ids":{"simkl":53536}}}""",
),
)
var committed = 0
val service = SimklMutationService(
client = SimklApiClient(
engine = engine,
accessToken = { "token" },
onUnauthorized = {},
nowEpochMs = { 0L },
sleep = {},
retryJitterMs = { 0L },
),
onMutationCommitted = { committed += 1 },
)
val result = service.scrobble(
action = TrackingScrobbleAction.STOP,
event = TrackingScrobbleEvent(movie(), progressPercent = 30.0),
)
assertEquals(listOf("/scrobble/stop"), engine.paths)
assertEquals(SimklScrobbleOutcome.PAUSE, result.outcome)
assertEquals(42L, result.playbackId)
assertEquals(30.0, result.progress)
assertEquals(0, committed)
}
private fun String.asObject() = json.parseToJsonElement(this).jsonObject
private fun movie() = TrackingMediaReference(

View file

@ -0,0 +1,218 @@
package com.nuvio.app.features.simkl
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SimklScrobbleReconciliationTest {
@Test
fun `pause replaces local playback without advancing the sync watermark`() {
val media = showMedia(simklId = 2090L, imdb = "tt1520211")
val snapshot = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.WATCHING,
show = media,
),
),
playback = listOf(
SimklPlaybackSession(
id = 4L,
progress = 20.0,
pausedAt = "2023-11-14T21:00:00Z",
type = "episode",
episode = SimklPlaybackEpisode(season = 1, number = 2),
show = media,
),
),
)
val updated = snapshot.applyScrobbleResult(
result = SimklScrobbleResult(
outcome = SimklScrobbleOutcome.PAUSE,
playbackId = 8L,
progress = 45.0,
mediaType = SimklMediaType.SHOWS,
media = media,
episode = SimklPlaybackEpisode(season = 1, number = 2),
),
committedAtEpochMs = 1_700_000_000_000L,
)
assertEquals("v1", updated.watermark)
assertEquals(1, updated.playback.size)
assertEquals(8L, updated.playback.single().id)
assertEquals(45.0, updated.playback.single().progress)
assertEquals("2023-11-14T22:13:20Z", updated.playback.single().pausedAt)
assertTrue(updated.toSimklWatchedProjection().items.isEmpty())
}
@Test
fun `completed stop records the exact episode and removes its playback`() {
val media = showMedia(simklId = 2090L, imdb = "tt1520211")
val snapshot = SimklSyncSnapshot(
isInitialized = true,
watermark = "v1",
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.WATCHING,
show = media,
seasons = listOf(
SimklSeason(
number = 1,
episodes = listOf(SimklEpisode(number = 2)),
),
),
),
),
playback = listOf(
SimklPlaybackSession(
id = 8L,
progress = 70.0,
pausedAt = "2023-11-14T21:00:00Z",
type = "episode",
episode = SimklPlaybackEpisode(season = 1, number = 2),
show = media,
),
),
)
val updated = snapshot.applyScrobbleResult(
result = SimklScrobbleResult(
outcome = SimklScrobbleOutcome.SCROBBLE,
playbackId = 9L,
progress = 90.0,
mediaType = SimklMediaType.SHOWS,
media = media,
episode = SimklPlaybackEpisode(season = 1, number = 2),
),
committedAtEpochMs = 1_700_000_000_000L,
)
assertEquals("v1", updated.watermark)
assertTrue(updated.playback.isEmpty())
assertEquals(1, updated.entries.single().watchedEpisodesCount)
val watched = updated.toSimklWatchedProjection().items.single()
assertEquals(1, watched.season)
assertEquals(2, watched.episode)
assertEquals(1_700_000_000_000L, watched.markedAtEpochMs)
}
@Test
fun `anime stop updates only the matching Simkl title when siblings share IMDb`() {
val firstMedia = animeMedia(100L, "tt2560140", 16498L)
val secondMedia = animeMedia(101L, "tt2560140", 25777L)
val snapshot = SimklSyncSnapshot(
entries = listOf(
animeEntry(firstMedia, season = 1, episode = 3),
animeEntry(secondMedia, season = 1, episode = 3, tvdbSeason = 2),
),
)
val updated = snapshot.applyScrobbleResult(
result = SimklScrobbleResult(
outcome = SimklScrobbleOutcome.SCROBBLE,
playbackId = 12L,
progress = 95.0,
mediaType = SimklMediaType.ANIME,
media = secondMedia,
episode = SimklPlaybackEpisode(
season = 1,
number = 3,
tvdbSeason = 2,
tvdbNumber = 3,
),
),
committedAtEpochMs = 1_700_000_000_000L,
)
val first = updated.entries.first { it.media?.ids?.simklIdValue() == "100" }
val second = updated.entries.first { it.media?.ids?.simklIdValue() == "101" }
assertNull(first.seasons.single().episodes.single().watchedAt)
assertEquals(
"2023-11-14T22:13:20Z",
second.seasons.single().episodes.single().watchedAt,
)
val watched = updated.toSimklWatchedProjection().items.single()
assertEquals(2, watched.season)
assertEquals(3, watched.episode)
}
@Test
fun `completed movie stop creates watched state without an activities refresh`() {
val media = SimklMedia(
title = "Inception",
year = 2010,
ids = mapOf(
"simkl" to JsonPrimitive(472214L),
"imdb" to JsonPrimitive("tt1375666"),
),
)
val updated = SimklSyncSnapshot().applyScrobbleResult(
result = SimklScrobbleResult(
outcome = SimklScrobbleOutcome.SCROBBLE,
playbackId = 15L,
progress = 95.0,
mediaType = SimklMediaType.MOVIES,
media = media,
episode = null,
watchedAt = "2023-11-14T21:00:00Z",
),
committedAtEpochMs = 1_700_000_000_000L,
)
assertEquals(SimklListStatus.COMPLETED, updated.entries.single().status)
assertEquals("2023-11-14T21:00:00Z", updated.entries.single().lastWatchedAt)
assertEquals("tt1375666", updated.toSimklWatchedProjection().items.single().id)
}
private fun showMedia(simklId: Long, imdb: String) = SimklMedia(
title = "The Walking Dead",
year = 2010,
ids = mapOf(
"simkl" to JsonPrimitive(simklId),
"imdb" to JsonPrimitive(imdb),
),
)
private fun animeMedia(simklId: Long, imdb: String, mal: Long) = SimklMedia(
title = "Attack on Titan",
year = 2013,
ids = mapOf(
"simkl" to JsonPrimitive(simklId),
"imdb" to JsonPrimitive(imdb),
"mal" to JsonPrimitive(mal),
),
)
private fun animeEntry(
media: SimklMedia,
season: Int,
episode: Int,
tvdbSeason: Int? = null,
) = SimklLibraryEntry(
mediaType = SimklMediaType.ANIME,
status = SimklListStatus.WATCHING,
show = media,
seasons = listOf(
SimklSeason(
number = season,
episodes = listOf(
SimklEpisode(
number = episode,
tvdb = tvdbSeason?.let { value ->
SimklEpisodeMapping(value, episode)
},
),
),
),
),
)
}

View file

@ -359,7 +359,7 @@ class SimklSyncEngineTest {
assertTrue("extended=full" in urls[0])
assertTrue("episode_watched_at=yes" in urls[0])
assertTrue("include_all_episodes=yes" in urls[0])
assertFalse("episode_tvdb_id=" in urls[0])
assertTrue("episode_tvdb_id=yes" in urls[0])
assertTrue("date_from=2026-05-08T14%3A23%3A11Z" in urls[1])
assertTrue("extended=full_anime_seasons" in urls[1])
assertTrue("episode_watched_at=yes" in urls[1])