aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt
blob: a87bffba7f3a2fffae0f7f9cac7086d0aead1261 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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<String> = 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<Photo>, c: SyncConfig = config(), ledger: Map<Long, PhotoLedger.Entry> = 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() })
    }
}