Skip to content

Commit 2c0cd3a

Browse files
committed
test: unit test for profile context wrapper
1 parent df3bacd commit 2c0cd3a

File tree

1 file changed

+218
-0
lines changed

1 file changed

+218
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.multiprofile
19+
20+
import android.content.Context
21+
import android.content.SharedPreferences
22+
import androidx.test.core.app.ApplicationProvider
23+
import org.junit.Assert.assertEquals
24+
import org.junit.Assert.assertNotEquals
25+
import org.junit.Assert.assertTrue
26+
import org.junit.Before
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.rules.TemporaryFolder
30+
import org.junit.runner.RunWith
31+
import org.mockito.ArgumentMatchers.anyInt
32+
import org.mockito.kotlin.any
33+
import org.mockito.kotlin.doReturn
34+
import org.mockito.kotlin.eq
35+
import org.mockito.kotlin.mock
36+
import org.mockito.kotlin.spy
37+
import org.mockito.kotlin.verify
38+
import org.robolectric.RobolectricTestRunner
39+
import java.io.File
40+
import java.io.IOException
41+
42+
@RunWith(RobolectricTestRunner::class)
43+
class ProfileContextWrapperTest {
44+
@get:Rule
45+
val tempFolder = TemporaryFolder()
46+
47+
private lateinit var baseContext: Context
48+
private lateinit var profileBaseDir: File
49+
private val profileId = ProfileId("p_test123")
50+
51+
@Before
52+
fun setUp() {
53+
// spy to verify calls passed to super.getSharedPreferences
54+
baseContext = spy(ApplicationProvider.getApplicationContext<Context>())
55+
56+
val appDataRoot = baseContext.filesDir.parentFile!!
57+
profileBaseDir = File(appDataRoot, "p_test123")
58+
59+
if (profileBaseDir.exists()) {
60+
profileBaseDir.deleteRecursively()
61+
}
62+
}
63+
64+
// --- Standard path tests ---
65+
66+
@Test
67+
fun `getFilesDir returns correct path and creates directory`() {
68+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
69+
val result = wrapper.filesDir
70+
val expected = File(profileBaseDir, "files")
71+
assertEquals(expected.absolutePath, result.absolutePath)
72+
assertTrue("Directory should be created", result.exists())
73+
}
74+
75+
@Test
76+
fun `getCacheDir returns correct path and creates directory`() {
77+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
78+
val result = wrapper.cacheDir
79+
val expected = File(profileBaseDir, "cache")
80+
assertEquals(expected.absolutePath, result.absolutePath)
81+
assertTrue(result.exists())
82+
}
83+
84+
@Test
85+
fun `getCodeCacheDir returns correct path and creates directory`() {
86+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
87+
val result = wrapper.codeCacheDir
88+
val expected = File(profileBaseDir, "code_cache")
89+
assertEquals(expected.absolutePath, result.absolutePath)
90+
assertTrue(result.exists())
91+
}
92+
93+
@Test
94+
fun `getNoBackupFilesDir returns correct path and creates directory`() {
95+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
96+
val result = wrapper.noBackupFilesDir
97+
val expected = File(profileBaseDir, "no_backup")
98+
assertEquals(expected.absolutePath, result.absolutePath)
99+
assertTrue(result.exists())
100+
}
101+
102+
@Test
103+
fun `getDatabasePath returns correct path inside databases folder`() {
104+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
105+
val dbName = "my_collection.db"
106+
val result = wrapper.getDatabasePath(dbName)
107+
val expectedDir = File(profileBaseDir, "databases")
108+
val expectedFile = File(expectedDir, dbName)
109+
assertEquals(expectedFile.absolutePath, result.absolutePath)
110+
assertTrue("Parent databases folder should be created", expectedDir.exists())
111+
}
112+
113+
// --- Security tests ---
114+
115+
@Test
116+
fun `getDir returns correct custom path for valid names`() {
117+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
118+
val dirName = "app_textures"
119+
val result = wrapper.getDir(dirName, Context.MODE_PRIVATE)
120+
val expected = File(profileBaseDir, dirName)
121+
assertEquals(expected.absolutePath, result.absolutePath)
122+
assertTrue(result.exists())
123+
}
124+
125+
@Test(expected = IllegalArgumentException::class)
126+
fun `getDir throws exception for directory traversal with dot dot`() {
127+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
128+
wrapper.getDir("..", Context.MODE_PRIVATE)
129+
}
130+
131+
@Test(expected = IllegalArgumentException::class)
132+
fun `getDatabasePath throws exception for directory traversal`() {
133+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
134+
wrapper.getDatabasePath("../dangerous.db")
135+
}
136+
137+
// --- Shared pref tests ---
138+
139+
@Test
140+
fun `getSharedPreferences prefixes name for custom profile`() {
141+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
142+
val originalName = "deck_options"
143+
144+
doReturn(mock<SharedPreferences>())
145+
.`when`(baseContext)
146+
.getSharedPreferences(any(), anyInt())
147+
148+
wrapper.getSharedPreferences(originalName, Context.MODE_PRIVATE)
149+
150+
val expectedName = "profile_${profileId.value}_$originalName"
151+
verify(baseContext).getSharedPreferences(eq(expectedName), eq(Context.MODE_PRIVATE))
152+
}
153+
154+
@Test
155+
fun `getSharedPreferences does not prefix name for default profile`() {
156+
val wrapper = ProfileContextWrapper.create(baseContext, ProfileId.DEFAULT, profileBaseDir)
157+
val originalName = "deck_options"
158+
159+
doReturn(mock<SharedPreferences>())
160+
.`when`(baseContext)
161+
.getSharedPreferences(any(), anyInt())
162+
163+
wrapper.getSharedPreferences(originalName, Context.MODE_PRIVATE)
164+
165+
verify(baseContext).getSharedPreferences(eq(originalName), eq(Context.MODE_PRIVATE))
166+
}
167+
168+
@Test
169+
fun `getSharedPreferences does not double-prefix if name is already prefixed`() {
170+
val wrapper = ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
171+
val alreadyPrefixedName = "profile_${profileId.value}_deck_options"
172+
173+
doReturn(mock<SharedPreferences>())
174+
.`when`(baseContext)
175+
.getSharedPreferences(any(), anyInt())
176+
177+
wrapper.getSharedPreferences(alreadyPrefixedName, Context.MODE_PRIVATE)
178+
179+
verify(baseContext).getSharedPreferences(eq(alreadyPrefixedName), eq(Context.MODE_PRIVATE))
180+
}
181+
182+
// --- Factory tests ---
183+
184+
@Test
185+
fun `create factory initializes directories immediately`() {
186+
profileBaseDir.deleteRecursively()
187+
188+
ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
189+
190+
val filesDir = File(profileBaseDir, "files")
191+
val databasesDir = File(profileBaseDir, "databases")
192+
193+
assertTrue("Files dir should be created by factory", filesDir.exists())
194+
assertTrue("Databases dir should be created by factory", databasesDir.exists())
195+
}
196+
197+
@Test(expected = IOException::class)
198+
fun `create factory throws IOException if directory creation fails`() {
199+
if (!profileBaseDir.exists()) {
200+
profileBaseDir.createNewFile()
201+
} else {
202+
profileBaseDir.deleteRecursively()
203+
profileBaseDir.createNewFile()
204+
}
205+
206+
ProfileContextWrapper.create(baseContext, profileId, profileBaseDir)
207+
}
208+
209+
@Test
210+
fun `default profile delegates to super and ignores profileBaseDir`() {
211+
val wrapper = ProfileContextWrapper.create(baseContext, ProfileId.DEFAULT, profileBaseDir)
212+
213+
val result = wrapper.filesDir
214+
215+
assertEquals(baseContext.filesDir.absolutePath, result.absolutePath)
216+
assertNotEquals(File(profileBaseDir, "files").absolutePath, result.absolutePath)
217+
}
218+
}

0 commit comments

Comments
 (0)