package org.meshbay.client import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.meshbay.client.phonesync.Destination import org.meshbay.client.photos.Photo import org.meshbay.client.photos.PhotoLedger import org.meshbay.client.photos.PhotoPlan import org.meshbay.client.photos.SyncConfig import java.util.TimeZone class PhotoSyncTest { @get:Rule val tmp = TemporaryFolder() private val utc = TimeZone.getTimeZone("UTC") // 2026-10-09 12:00:00 UTC private val oct9 = 1791547200000L private fun photo(id: Long, name: String = "IMG_$id.jpg", size: Long = 100, taken: Long = oct9, added: Long = oct9 / 1000, modified: Long = oct9 / 1000, bucket: String = "cam", mime: String = "image/jpeg", video: Boolean = false) = Photo(id, name, size, taken, added, modified, bucket, mime, video) private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List = listOf("cam"), videos: Boolean = false) = SyncConfig(albums, includeExisting, since, videos) private val base = PhotoPlan.baseFor(Destination("bob", "g1", "Mine", "Backups")) private fun entry(p: Photo, sha: String = "h", name: String = p.displayName) = PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, "d", name, 1) private fun plan(photos: List, c: SyncConfig = config(), ledger: Map = emptyMap(), same: Boolean = false) = PhotoPlan.plan(photos, c, base, { ledger[it] }, utc) { _, _ -> same } // ── what is sent ────────────────────────────────────────────────────────── @Test fun `everything already on the phone is sent, newest first, under its year and month`() { val out = plan(listOf(photo(1, taken = oct9 - 40L * 86400000), photo(2), photo(3, taken = oct9 - 86400000))) assertEquals(listOf(2L, 3L, 1L), out.map { it.photo.mediaId }) assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir) assertEquals("Backups/bob-photos/2026/2026-08", out[2].dir) assertEquals("IMG_2.jpg", out[0].name) } @Test fun `from now on leaves out what was on the phone before`() { val since = oct9 + 1000 val out = plan(listOf(photo(1), photo(2, added = (oct9 + 5000) / 1000)), config(includeExisting = false, since = since)) assertEquals(listOf(2L), out.map { it.photo.mediaId }) } @Test fun `only the chosen albums, and only images`() { val out = plan(listOf(photo(1), photo(2, bucket = "screens"), photo(3, mime = "video/mp4"))) assertEquals(listOf(1L), out.map { it.photo.mediaId }) } @Test fun `videos of the same albums go beside the photos, only when asked for`() { val clip = photo(5, name = "PXL_20261009_120000.mp4", mime = "video/mp4", video = true) assertTrue(plan(listOf(clip)).isEmpty()) val out = plan(listOf(photo(1), clip), config(videos = true)) assertEquals(setOf(1L, 5L), out.map { it.photo.mediaId }.toSet()) assertEquals("Backups/bob-photos/2026/2026-10", out.first { it.photo.video }.dir) assertTrue("a video file is not taken for a photo", plan(listOf(photo(6, mime = "video/mp4")), config(videos = true)).isEmpty()) assertEquals("video-9.mp4", PhotoPlan.nameFor(photo(9, name = "_clip.mp4", mime = "video/mp4", video = true))) assertEquals("video-9.mov", PhotoPlan.nameFor(photo(9, name = "", mime = "video/quicktime", video = true))) } @Test fun `ticking videos in keeps the starting point`() { val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) val first = SyncConfig.fromJson(o, 100) val withVideos = SyncConfig.fromJson(o.put("includeVideos", true), 200, first) assertEquals(100, withVideos.since) assertTrue(withVideos.includeVideos) assertEquals(withVideos, SyncConfig.parse(withVideos.toJson().toString())) assertFalse(SyncConfig.parse("""{"albums":[],"since":1}""")!!.includeVideos) } @Test fun `a photo already sent is not sent again`() { val p = photo(1) assertTrue(plan(listOf(p), ledger = mapOf(1L to entry(p))).isEmpty()) } @Test fun `a photo deleted on the phone is simply not listed, and nothing is asked of the node`() { // The plan has only additions in it: there is no other kind of item. val sent = photo(1) assertTrue(plan(emptyList(), ledger = mapOf(1L to entry(sent))).isEmpty()) } // ── edits ──────────────────────────────────────────────────────────────── @Test fun `an edit is sent beside the original under a name that says so`() { val before = photo(1) val after = before.copy(size = 120, modified = before.modified + 3600) val out = plan(listOf(after), ledger = mapOf(1L to entry(before))) assertEquals(1, out.size) assertTrue(out[0].edited) assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name) assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir) } @Test fun `a touch that left the bytes alone sends nothing`() { val before = photo(1) val touched = before.copy(modified = before.modified + 60) assertTrue(plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = true).isEmpty()) assertEquals(1, plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = false).size) } @Test fun `the bytes are only read when the date moved and the size did not`() { val before = photo(1) var asked = 0 PhotoPlan.plan(listOf(before), config(), base, { entry(before) }, utc) { _, _ -> asked++; true } PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), base, { entry(before) }, utc) { _, _ -> asked++; true } assertEquals(0, asked) } // ── names ──────────────────────────────────────────────────────────────── @Test fun `a name the node would refuse is replaced before it is sent`() { assertEquals("IMG_1.jpg", PhotoPlan.nameFor(photo(1))) assertEquals("photo-7.jpg", PhotoPlan.nameFor(photo(7, name = ".hidden.jpg"))) assertEquals("photo-8.png", PhotoPlan.nameFor(photo(8, name = "_x.png"))) assertEquals("photo-9.jpg", PhotoPlan.nameFor(photo(9, name = ""))) assertTrue(PhotoPlan.UPLOAD_NAME.matches("PXL_20261009_120000123.jpg")) assertFalse(PhotoPlan.UPLOAD_NAME.matches("a.jpg.")) } @Test fun `an edited name stays within the node's length`() { val long = photo(1, name = "A".repeat(124) + ".jpg", modified = 1) val name = PhotoPlan.editedName(long, utc) assertTrue(name.length <= 128) assertTrue(PhotoPlan.UPLOAD_NAME.matches(name)) } // ── when ───────────────────────────────────────────────────────────────── @Test fun `once a day, counted from the last run that finished`() { assertTrue(PhotoPlan.due(null, oct9)) assertFalse(PhotoPlan.due(oct9 - 3600_000, oct9)) assertTrue(PhotoPlan.due(oct9 - PhotoPlan.DAY_MS, oct9)) assertTrue("a clock moved back must not stop backups for good", PhotoPlan.due(oct9 + 3600_000, oct9)) } // ── settings from the page ─────────────────────────────────────────────── @Test fun `photos go into the account's own folder under the destination`() { assertEquals("Backups/bob-photos", base) } @Test fun `changing the albums keeps the starting point, changing the scope moves it`() { val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) val first = SyncConfig.fromJson(o, 100) assertEquals(100, SyncConfig.fromJson(o.put("albums", JSONArray(listOf("cam", "x"))), 200, first).since) assertEquals(300, SyncConfig.fromJson(o.put("includeExisting", true), 300, first).since) } @Test fun `settings survive being stored`() { val c = config(albums = listOf("a", "b")) assertEquals(c, SyncConfig.parse(c.toJson().toString())) assertNull(SyncConfig.parse("{}")) } // ── the ledger ─────────────────────────────────────────────────────────── @Test fun `the ledger remembers across a restart, last line wins`() { val f = tmp.newFile("l.jsonl") PhotoLedger(f).apply { record(entry(photo(1), sha = "a")) record(entry(photo(2), sha = "b")) record(entry(photo(1), sha = "c")) } val again = PhotoLedger(f) assertEquals(2, again.size) assertEquals("c", again[1]!!.sha256) } @Test fun `a line cut short by a killed process costs that one photo, not the ledger`() { val f = tmp.newFile("l.jsonl") PhotoLedger(f).record(entry(photo(1))) f.appendText("{\"id\":2,\"m\":") val again = PhotoLedger(f) assertEquals(1, again.size) assertNull(again[2]) } @Test fun `a ledger rewritten many times is compacted on load`() { val f = tmp.newFile("l.jsonl") val l = PhotoLedger(f) repeat(300) { l.record(entry(photo(1), sha = "s$it")) } val again = PhotoLedger(f) assertEquals(1, again.size) assertEquals("s299", again[1]!!.sha256) assertEquals(1, f.readLines().count { it.isNotBlank() }) } }