Skip to content

Commit 675b8dd

Browse files
committed
feat: chunked upload code quality improvements + comprehensive testing suite (23 unit tests + instrumented tests)
Signed-off-by: Raghav Garg <[email protected]>
1 parent 16fa216 commit 675b8dd

File tree

7 files changed

+1789
-0
lines changed

7 files changed

+1789
-0
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
6+
*/
7+
package com.nextcloud.client.jobs.upload
8+
9+
import android.content.Context
10+
import androidx.test.platform.app.InstrumentationRegistry
11+
import androidx.test.ext.junit.runners.AndroidJUnit4
12+
import com.nextcloud.client.account.UserAccountManagerImpl
13+
import com.owncloud.android.datamodel.UploadsStorageManager
14+
import com.owncloud.android.db.OCUpload
15+
import com.owncloud.android.files.services.NameCollisionPolicy
16+
import com.owncloud.android.operations.FixedChunkUploadRemoteOperation
17+
import org.junit.After
18+
import org.junit.Assert.*
19+
import org.junit.Before
20+
import org.junit.Test
21+
import org.junit.runner.RunWith
22+
import java.io.File
23+
import java.io.FileOutputStream
24+
25+
@RunWith(AndroidJUnit4::class)
26+
class FileUploadWorkerInstrumentedTest {
27+
28+
private lateinit var context: Context
29+
private lateinit var uploadsStorageManager: UploadsStorageManager
30+
private lateinit var tempDir: File
31+
32+
@Before
33+
fun setUp() {
34+
context = InstrumentationRegistry.getInstrumentation().targetContext
35+
36+
// Initialize UploadsStorageManager with real database
37+
uploadsStorageManager = UploadsStorageManager(
38+
UserAccountManagerImpl.fromContext(context),
39+
context.contentResolver
40+
)
41+
42+
// Create temp directory for test files
43+
tempDir = File(context.cacheDir, "file_upload_worker_test")
44+
if (!tempDir.exists()) {
45+
tempDir.mkdirs()
46+
}
47+
48+
// Clean up any existing uploads
49+
uploadsStorageManager.removeAllUploads()
50+
}
51+
52+
@After
53+
fun tearDown() {
54+
// Clean up test files
55+
if (tempDir.exists()) {
56+
deleteRecursive(tempDir)
57+
}
58+
59+
// Clean up uploads
60+
uploadsStorageManager.removeAllUploads()
61+
}
62+
63+
@Test
64+
fun testFileUploadWorkerConstants() {
65+
// Test that FileUploadWorker constants are correctly defined
66+
assertEquals("ACCOUNT constant", "data_account", FileUploadWorker.ACCOUNT)
67+
assertEquals("UPLOAD_IDS constant", "uploads_ids", FileUploadWorker.UPLOAD_IDS)
68+
assertEquals("LOCAL_BEHAVIOUR_COPY", 0, FileUploadWorker.LOCAL_BEHAVIOUR_COPY)
69+
assertEquals("LOCAL_BEHAVIOUR_MOVE", 1, FileUploadWorker.LOCAL_BEHAVIOUR_MOVE)
70+
assertEquals("LOCAL_BEHAVIOUR_FORGET", 2, FileUploadWorker.LOCAL_BEHAVIOUR_FORGET)
71+
assertEquals("LOCAL_BEHAVIOUR_DELETE", 3, FileUploadWorker.LOCAL_BEHAVIOUR_DELETE)
72+
}
73+
74+
@Test
75+
fun testDeterministicNotificationIdGeneration() {
76+
// Test notification ID generation with real file system
77+
val testFile1 = createTestFile("notification_test1.txt", 1024L)
78+
val testFile2 = createTestFile("notification_test2.txt", 2048L)
79+
80+
// Test same file produces same notification ID
81+
val id1a = generateTestNotificationId(testFile1.absolutePath, testFile1.length())
82+
val id1b = generateTestNotificationId(testFile1.absolutePath, testFile1.length())
83+
assertEquals("Same file should generate same notification ID", id1a, id1b)
84+
85+
// Test different files produce different notification IDs
86+
val id2 = generateTestNotificationId(testFile2.absolutePath, testFile2.length())
87+
assertNotEquals("Different files should generate different notification IDs", id1a, id2)
88+
89+
// Test different file sizes produce different notification IDs
90+
val id1c = generateTestNotificationId(testFile1.absolutePath, 4096L)
91+
assertNotEquals("Different file sizes should generate different notification IDs", id1a, id1c)
92+
93+
// Verify all IDs are positive
94+
assertTrue("Notification IDs should be positive", id1a > 0)
95+
assertTrue("Notification IDs should be positive", id1b > 0)
96+
assertTrue("Notification IDs should be positive", id2 > 0)
97+
assertTrue("Notification IDs should be positive", id1c > 0)
98+
}
99+
100+
@Test
101+
fun testRealDatabaseOperations() {
102+
// Test FileUploadWorker integration with real UploadsStorageManager database
103+
val testFile = createTestFile("database_test.txt", 1024L)
104+
105+
// Create upload entry
106+
val upload = OCUpload(
107+
testFile.absolutePath,
108+
"/remote/database_test.txt",
109+
110+
).apply {
111+
nameCollisionPolicy = NameCollisionPolicy.DEFAULT
112+
localAction = FileUploadWorker.LOCAL_BEHAVIOUR_COPY
113+
isUseWifiOnly = false
114+
isWhileChargingOnly = false
115+
}
116+
117+
// Store in database
118+
val uploadId = uploadsStorageManager.storeUpload(upload)
119+
assertTrue("Upload should be stored successfully", uploadId > 0)
120+
121+
// Verify upload can be retrieved
122+
val retrievedUpload = uploadsStorageManager.getUploadById(uploadId)
123+
assertNotNull("Upload should be retrievable", retrievedUpload)
124+
assertEquals("Local paths should match", upload.localPath, retrievedUpload?.localPath)
125+
assertEquals("Remote paths should match", upload.remotePath, retrievedUpload?.remotePath)
126+
assertEquals("Account names should match", upload.accountName, retrievedUpload?.accountName)
127+
128+
// Note: updateUploadStatus with just status is private, so we'll skip this test
129+
130+
// Test upload removal
131+
uploadsStorageManager.removeUpload(uploadId)
132+
val deletedUpload = uploadsStorageManager.getUploadById(uploadId)
133+
assertNull("Upload should be removed", deletedUpload)
134+
}
135+
136+
@Test
137+
fun testFixedChunkUploadRemoteOperationCreation() {
138+
// Test that FixedChunkUploadRemoteOperation can be created
139+
val testFile = createTestFile("chunk_test.bin", 3 * 1024 * 1024L) // 3MB
140+
141+
val operation = FixedChunkUploadRemoteOperation(
142+
testFile.absolutePath,
143+
"/remote/chunk_test.bin",
144+
"application/octet-stream",
145+
null,
146+
System.currentTimeMillis(),
147+
null,
148+
false,
149+
context
150+
)
151+
152+
assertNotNull("Chunk upload operation should be created", operation)
153+
assertFalse("Operation should not be cancelled initially", operation.isCancelled())
154+
assertEquals("Fixed chunk size should be 1MB", 1024 * 1024, FixedChunkUploadRemoteOperation.FIXED_CHUNK_SIZE)
155+
}
156+
157+
@Test
158+
fun testProgressCalculationWithRealFiles() {
159+
// Test progress calculation with real file sizes
160+
val smallFile = createTestFile("small_progress.txt", 1024L)
161+
val mediumFile = createTestFile("medium_progress.txt", 1024 * 512L) // 512KB
162+
val largeFile = createTestFile("large_progress.txt", 1024 * 1024 * 3L) // 3MB
163+
164+
// Test progress calculations
165+
assertEquals("0% for 0 transferred", 0, calculatePercent(0, smallFile.length()))
166+
assertEquals("50% for half transferred", 50, calculatePercent(smallFile.length() / 2, smallFile.length()))
167+
assertEquals("100% for fully transferred", 100, calculatePercent(smallFile.length(), smallFile.length()))
168+
169+
// Test with different file sizes
170+
assertEquals("25% for quarter of medium file", 25, calculatePercent(mediumFile.length() / 4, mediumFile.length()))
171+
assertEquals("75% for three quarters of large file", 75, calculatePercent(largeFile.length() * 3 / 4, largeFile.length()))
172+
173+
// Test edge cases
174+
assertEquals("0% for zero total", 0, calculatePercent(100, 0))
175+
assertEquals("100% for over-transferred", 100, calculatePercent(150, 100))
176+
}
177+
178+
@Test
179+
fun testChunkedUploadThresholds() {
180+
// Test that files above threshold would trigger chunked upload logic
181+
val smallFile = createTestFile("small_threshold.txt", 1024L) // 1KB
182+
val largeFile = createTestFile("large_threshold.txt", 3 * 1024 * 1024L) // 3MB
183+
184+
assertTrue("Small file should be below chunk threshold",
185+
smallFile.length() < FixedChunkUploadRemoteOperation.FIXED_CHUNK_SIZE)
186+
assertTrue("Large file should be above chunk threshold",
187+
largeFile.length() > FixedChunkUploadRemoteOperation.FIXED_CHUNK_SIZE)
188+
189+
// Calculate expected chunk count for large file
190+
val expectedChunks = (largeFile.length() + FixedChunkUploadRemoteOperation.FIXED_CHUNK_SIZE - 1) /
191+
FixedChunkUploadRemoteOperation.FIXED_CHUNK_SIZE
192+
assertTrue("Large file should require multiple chunks", expectedChunks > 1)
193+
assertEquals("Should calculate 3 chunks for 3MB file", 3, expectedChunks)
194+
}
195+
196+
// Helper methods
197+
198+
private fun createTestFile(fileName: String, size: Long): File {
199+
val testFile = File(tempDir, fileName)
200+
FileOutputStream(testFile).use { fos ->
201+
val buffer = ByteArray(8192)
202+
var bytesWritten = 0L
203+
204+
// Fill buffer with test data
205+
for (i in buffer.indices) {
206+
buffer[i] = (i % 256).toByte()
207+
}
208+
209+
while (bytesWritten < size) {
210+
val bytesToWrite = Math.min(buffer.size.toLong(), size - bytesWritten).toInt()
211+
fos.write(buffer, 0, bytesToWrite)
212+
bytesWritten += bytesToWrite
213+
}
214+
}
215+
216+
assertTrue("Test file should exist", testFile.exists())
217+
assertEquals("Test file should have correct size", size, testFile.length())
218+
return testFile
219+
}
220+
221+
private fun deleteRecursive(file: File) {
222+
if (file.isDirectory) {
223+
file.listFiles()?.forEach { deleteRecursive(it) }
224+
}
225+
file.delete()
226+
}
227+
228+
private fun generateTestNotificationId(localPath: String, fileSize: Long): Int {
229+
return try {
230+
val file = File(localPath)
231+
val canonicalPath = try {
232+
file.canonicalPath
233+
} catch (e: java.io.IOException) {
234+
localPath
235+
}
236+
val baseString = "${canonicalPath}_$fileSize"
237+
val hash = baseString.hashCode()
238+
Math.abs(hash)
239+
} catch (e: Exception) {
240+
Math.abs("${localPath}_$fileSize".hashCode())
241+
}
242+
}
243+
244+
private fun calculatePercent(transferred: Long, total: Long): Int {
245+
return if (total == 0L) 0 else (100.0 * transferred / total).toInt().coerceAtMost(100)
246+
}
247+
}

0 commit comments

Comments
 (0)