Repo created
This commit is contained in:
parent
324070df30
commit
2d33a757bf
644 changed files with 99721 additions and 2 deletions
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalLibrariesTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOkhttpHttpUrl_PublicSuffixList() {
|
||||
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
|
||||
// In Android, loading the PublicSuffixList is done over AndroidX startup.
|
||||
// This test verifies that everything is working.
|
||||
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
|
||||
import at.bitfire.davdroid.test.BuildConfig
|
||||
import at.bitfire.synctools.log.LogcatHandler
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@Suppress("unused")
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
|
||||
override fun onCreate(arguments: Bundle?) {
|
||||
super.onCreate(arguments)
|
||||
|
||||
// set root logger to adb Logcat
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = Level.ALL
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
|
||||
|
||||
// MockK requirements
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
|
||||
}
|
||||
58
app/src/androidTest/kotlin/at/bitfire/davdroid/TestUtils.kt
Normal file
58
app/src/androidTest/kotlin/at/bitfire/davdroid/TestUtils.kt
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import org.junit.Assert.assertTrue
|
||||
import kotlin.math.abs
|
||||
|
||||
object TestUtils {
|
||||
|
||||
fun assertWithin(expected: Long, actual: Long, tolerance: Long) {
|
||||
val absDifference = abs(expected - actual)
|
||||
assertTrue(
|
||||
"$actual not within ($expected ± $tolerance)",
|
||||
absDifference <= tolerance
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes WorkManager for instrumentation tests.
|
||||
*/
|
||||
fun setUpWorkManager(context: Context, workerFactory: WorkerFactory? = null) {
|
||||
val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG)
|
||||
if (workerFactory != null)
|
||||
config.setWorkerFactory(workerFactory)
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config.build())
|
||||
}
|
||||
|
||||
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
|
||||
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
|
||||
.fromUniqueWorkNames(listOf(workerName))
|
||||
.addStates(states)
|
||||
.build()
|
||||
).get().isNotEmpty()
|
||||
|
||||
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING
|
||||
))
|
||||
|
||||
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
|
||||
workInStates(context, workerName, listOf(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING,
|
||||
WorkInfo.State.SUCCEEDED
|
||||
))
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AppDatabaseTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version.
|
||||
*/
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// Create DB with v8
|
||||
MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
).createDatabase(TEST_DB, 8).close()
|
||||
|
||||
// open and migrate (to current version) database
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// manual migrations
|
||||
.addMigrations(*manualMigrations.toTypedArray())
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in autoMigrations)
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
.build()
|
||||
.openHelper.writableDatabase // this will run all migrations
|
||||
.close()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TEST_DB = "test"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar_FullTimezone() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>BEGIN:VCALENDAR\n" +
|
||||
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
|
||||
"VERSION:2.0\n" +
|
||||
"BEGIN:VTIMEZONE\n" +
|
||||
"TZID:US-Eastern\n" +
|
||||
"LAST-MODIFIED:19870101T000000Z\n" +
|
||||
"BEGIN:STANDARD\n" +
|
||||
"DTSTART:19671029T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
|
||||
"TZOFFSETFROM:-0400\n" +
|
||||
"TZOFFSETTO:-0500\n" +
|
||||
"TZNAME:Eastern Standard Time (US & Canada)\n" +
|
||||
"END:STANDARD\n" +
|
||||
"BEGIN:DAYLIGHT\n" +
|
||||
"DTSTART:19870405T020000\n" +
|
||||
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
|
||||
"TZOFFSETFROM:-0500\n" +
|
||||
"TZOFFSETTO:-0400\n" +
|
||||
"TZNAME:Eastern Daylight Time (US & Canada)\n" +
|
||||
"END:DAYLIGHT\n" +
|
||||
"END:VTIMEZONE\n" +
|
||||
"END:VCALENDAR\n" +
|
||||
"</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar_OnlyTzId() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone-id>US-Eastern</CAL:calendar-timezone-id>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("US-Eastern", info.timezoneId)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseWebcal() {
|
||||
// Webcal subscription
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
|
||||
"<response>" +
|
||||
" <href>/webcal1</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <displayname>Sample Subscription</displayname>" +
|
||||
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
|
||||
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
assertEquals("Sample Subscription", info.displayName)
|
||||
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomeSetDaoTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
lateinit var dao: HomeSetDao
|
||||
var serviceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
dao = db.homeSetDao()
|
||||
|
||||
serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.serviceDao().deleteAll()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.copy(id = 1L), dao.getById(1))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.copy(id = 2L), dao.getById(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate_TransactionSafe() {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
for (i in 0..9999)
|
||||
launch {
|
||||
dao.insertOrUpdateByUrlBlocking(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = serviceId,
|
||||
url = "https://example.com/".toHttpUrl(),
|
||||
personal = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
assertEquals(1, dao.getByService(serviceId).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, dao.getById(1L))
|
||||
|
||||
dao.delete(entry1)
|
||||
assertEquals(null, dao.getById(1L))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [ SingletonComponent::class ],
|
||||
replaces = [
|
||||
AppDatabase.AppDatabaseModule::class
|
||||
]
|
||||
)
|
||||
class MemoryDbModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun inMemoryDatabase(
|
||||
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
|
||||
@ApplicationContext context: Context
|
||||
): AppDatabase =
|
||||
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
// auto-migration specs that need to be specified explicitly
|
||||
.apply {
|
||||
for (spec in autoMigrations) {
|
||||
addAutoMigrationSpec(spec)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@HiltAndroidTest
|
||||
class PrincipalDaoTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
private lateinit var principalDao: PrincipalDao
|
||||
private lateinit var service: Service
|
||||
private val url = "https://example.com/dav/principal".toHttpUrl()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
principalDao = spyk(db.principalDao())
|
||||
|
||||
service = Service(id = 1, accountName = "account", type = "webdav")
|
||||
db.serviceDao().insertOrReplace(service)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_insertsIfNotExisting() = runTest {
|
||||
val principal = Principal(serviceId = service.id, url = url, displayName = "principal")
|
||||
val id = principalDao.insertOrUpdate(service.id, principal)
|
||||
assertTrue(id > 0)
|
||||
|
||||
val stored = principalDao.get(id)
|
||||
assertEquals("principal", stored.displayName)
|
||||
verify(exactly = 0) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_doesNotUpdateIfDisplayNameIsEqual() = runTest {
|
||||
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
|
||||
|
||||
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
|
||||
|
||||
assertEquals(idOld, idNew)
|
||||
val stored = principalDao.get(idOld)
|
||||
assertEquals("principalOld", stored.displayName)
|
||||
verify(exactly = 0) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertOrUpdate_updatesIfDisplayNameIsDifferent() = runTest {
|
||||
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
|
||||
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
|
||||
|
||||
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalNew")
|
||||
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
|
||||
|
||||
assertEquals(idOld, idNew)
|
||||
|
||||
val updated = principalDao.get(idOld)
|
||||
assertEquals("principalNew", updated.displayName)
|
||||
verify(exactly = 1) { principalDao.update(any()) }
|
||||
}
|
||||
|
||||
@Test(expected = SQLiteConstraintException::class)
|
||||
fun insertOrUpdate_throwsForeignKeyConstraintViolationException() = runTest {
|
||||
// throws on non-existing service
|
||||
val url = "https://example.com/dav/principal".toHttpUrl()
|
||||
val principal1 = Principal(serviceId = 999, url = url, displayName = "p1")
|
||||
principalDao.insertOrUpdate(999, principal1)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.sqlite.SQLiteException
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncStatsDaoTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
var collectionId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val serviceId = db.serviceDao().insertOrReplace(Service(
|
||||
id = 0,
|
||||
accountName = "test@example.com",
|
||||
type = Service.TYPE_CALDAV
|
||||
))
|
||||
collectionId = db.collectionDao().insert(Collection(
|
||||
id = 0,
|
||||
serviceId = serviceId,
|
||||
type = Collection.TYPE_CALENDAR,
|
||||
url = "https://example.com".toHttpUrl()
|
||||
))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.serviceDao().deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertOrReplace_ExistingForeignKey() = runTest {
|
||||
val dao = db.syncStatsDao()
|
||||
dao.insertOrReplace(
|
||||
SyncStats(
|
||||
id = 0,
|
||||
collectionId = collectionId,
|
||||
dataType = SyncDataType.CONTACTS.toString(),
|
||||
lastSync = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = SQLiteException::class)
|
||||
fun testInsertOrReplace_MissingForeignKey() = runTest {
|
||||
val dao = db.syncStatsDao()
|
||||
dao.insertOrReplace(
|
||||
SyncStats(
|
||||
id = 0,
|
||||
collectionId = 12345,
|
||||
dataType = SyncDataType.CONTACTS.toString(),
|
||||
lastSync = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class WebDavDocumentDaoTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testGetChildren() = runTest {
|
||||
val mountDao = db.webDavMountDao()
|
||||
val dao = db.webDavDocumentDao()
|
||||
|
||||
val mount = WebDavMount(id = 1, name = "Test", url = "https://example.com/".toHttpUrl())
|
||||
db.webDavMountDao().insert(mount)
|
||||
|
||||
val root = WebDavDocument(
|
||||
id = 1,
|
||||
mountId = mount.id,
|
||||
parentId = null,
|
||||
name = "Root Document"
|
||||
)
|
||||
dao.insertOrReplace(root)
|
||||
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 1", displayName = "DisplayName 2"))
|
||||
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 2", displayName = "DisplayName 1"))
|
||||
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 3", displayName = "Directory 1", isDirectory = true))
|
||||
try {
|
||||
dao.getChildren(root.id, orderBy = "name DESC").let { result ->
|
||||
logger.log(Level.INFO, "getChildren single sort Result", result)
|
||||
|
||||
assertEquals(listOf(
|
||||
"Name 3",
|
||||
"Name 2",
|
||||
"Name 1"
|
||||
), result.map { it.name })
|
||||
}
|
||||
|
||||
dao.getChildren(root.id, orderBy = "isDirectory DESC, name ASC").let { result ->
|
||||
logger.log(Level.INFO, "getChildren multiple sort Result", result)
|
||||
|
||||
assertEquals(listOf(
|
||||
"Name 3",
|
||||
"Name 1",
|
||||
"Name 2"
|
||||
), result.map { it.name })
|
||||
}
|
||||
} finally {
|
||||
mountDao.deleteAsync(mount)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class AutoMigration16Test: DatabaseMigrationTest(toVersion = 16) {
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithTimeZone() = testMigration(
|
||||
prepare = { db ->
|
||||
val minimalVTimezone = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
""".trimIndent()
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals("America/New_York", cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithTimeZone_Unparseable() = testMigration(
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content")
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_WithoutTimezone() = testMigration(
|
||||
prepare = { db ->
|
||||
db.execSQL(
|
||||
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
|
||||
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false)
|
||||
)
|
||||
}
|
||||
) { db ->
|
||||
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertNull(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class AutoMigration18Test : DatabaseMigrationTest(toVersion = 18) {
|
||||
|
||||
@Test
|
||||
fun testMigration_AllAuthorities() = testMigration(
|
||||
prepare = { db ->
|
||||
// Insert service and collection to respect relation constraints
|
||||
db.execSQL("INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", arrayOf<Any?>(1, "test", 1))
|
||||
listOf(1L, 2L, 3L).forEach { id ->
|
||||
db.execSQL(
|
||||
"INSERT INTO collection (id, serviceId, url, type, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(id, 1, "https://example.com/$id", 1, 1, 1, 0, 1)
|
||||
)
|
||||
}
|
||||
// Insert some syncstats with authorities and lastSync times
|
||||
val syncstats = listOf(
|
||||
Entry(1, 1, "com.android.contacts", 1000),
|
||||
Entry(2, 1, "com.android.calendar", 1000),
|
||||
Entry(3, 1, "org.dmfs.tasks", 1000),
|
||||
Entry(4, 1, "org.tasks.opentasks", 2000),
|
||||
Entry(5, 1, "at.techbee.jtx.provider", 3000), // highest lastSync for collection 1
|
||||
Entry(6, 1, "unknown.authority", 1000), // ignored
|
||||
|
||||
Entry(7, 2, "org.dmfs.tasks", 1000),
|
||||
Entry(8, 2, "org.tasks.opentasks", 2000), // highest lastSync for collection 2
|
||||
|
||||
Entry(9, 3, "org.tasks.opentasks", 1000),
|
||||
)
|
||||
syncstats.forEach { (id, collectionId, authority, lastSync) ->
|
||||
db.execSQL(
|
||||
"INSERT INTO syncstats (id, collectionId, authority, lastSync) VALUES (?, ?, ?, ?)",
|
||||
arrayOf<Any?>(id, collectionId, authority, lastSync)
|
||||
)
|
||||
}
|
||||
},
|
||||
validate = { db ->
|
||||
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
|
||||
val found = mutableListOf<Entry>()
|
||||
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
|
||||
val idIdx = cursor.getColumnIndex("id")
|
||||
val colIdx = cursor.getColumnIndex("collectionId")
|
||||
val typeIdx = cursor.getColumnIndex("dataType")
|
||||
while (cursor.moveToNext())
|
||||
found.add(
|
||||
Entry(cursor.getInt(idIdx), cursor.getLong(colIdx), cursor.getString(typeIdx))
|
||||
)
|
||||
}
|
||||
|
||||
// Expect one TASKS row per collection (collections 1, 2, 3)
|
||||
assertEquals(
|
||||
listOf(
|
||||
Entry(1, 1, "CONTACTS"),
|
||||
Entry(2, 1, "EVENTS"),
|
||||
Entry(5, 1, "TASKS"), // highest lastSync TASK for collection 1 is JTX Board
|
||||
Entry(8, 2, "TASKS"), // highest lastSync TASK for collection 2
|
||||
Entry(9, 3, "TASKS"), // only TASK for collection 3
|
||||
), found
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
data class Entry(
|
||||
val id: Int,
|
||||
val collectionId: Long,
|
||||
val dataType: String? = null,
|
||||
val lastSync: Long? = null
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db.migration
|
||||
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Helper for testing the database migration from [toVersion] - 1 to [toVersion].
|
||||
*
|
||||
* @param toVersion The target version to migrate to.
|
||||
*/
|
||||
abstract class DatabaseMigrationTest(
|
||||
private val toVersion: Int
|
||||
) {
|
||||
|
||||
@Inject
|
||||
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
|
||||
|
||||
@Inject
|
||||
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used for testing the migration process from [toVersion]-1 to [toVersion].
|
||||
*
|
||||
* Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need
|
||||
* to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening
|
||||
* a new database connection (works per connection). In tests it's usually more practical
|
||||
* not to do so, however. In production database connections room enables it for us.
|
||||
*
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
protected fun testMigration(
|
||||
prepare: (SupportSQLiteDatabase) -> Unit,
|
||||
validate: (SupportSQLiteDatabase) -> Unit
|
||||
) {
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
autoMigrations.toList(),
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
// Prepare the database with the initial version.
|
||||
val dbName = "test"
|
||||
helper.createDatabase(dbName, version = toVersion - 1).apply {
|
||||
// We could enable foreign key constraint enforcement here
|
||||
// by setting "PRAGMA foreign_keys=ON".
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
// Re-open the database with the new version and provide all the migrations.
|
||||
val db = helper.runMigrationsAndValidate(
|
||||
name = dbName,
|
||||
version = toVersion,
|
||||
validateDroppedTables = true,
|
||||
migrations = manualMigrations.toTypedArray()
|
||||
)
|
||||
|
||||
validate(db)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.sync.FakeSyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
|
||||
@Module
|
||||
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
|
||||
abstract class FakeSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule.standardTestDispatcher
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.setMain
|
||||
|
||||
/**
|
||||
* Provides test dispatchers to be injected instead of the normal ones.
|
||||
*
|
||||
* The [standardTestDispatcher] is set as main dispatcher in [at.bitfire.davdroid.HiltTestRunner],
|
||||
* so that tests can just use [kotlinx.coroutines.test.runTest] without providing [standardTestDispatcher].
|
||||
*/
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [CoroutineDispatchersModule::class]
|
||||
)
|
||||
object TestCoroutineDispatchersModule {
|
||||
|
||||
private val standardTestDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Provides
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = standardTestDispatcher
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = standardTestDispatcher
|
||||
|
||||
@Provides
|
||||
@MainDispatcher
|
||||
fun mainDispatcher(): CoroutineDispatcher = standardTestDispatcher
|
||||
|
||||
@Provides
|
||||
@SyncDispatcher
|
||||
fun syncDispatcher(): CoroutineDispatcher = standardTestDispatcher
|
||||
|
||||
/**
|
||||
* Sets the [standardTestDispatcher] as [Dispatchers.Main] so that test dispatchers
|
||||
* created in the future use the same scheduler. See [StandardTestDispatcher] docs
|
||||
* for more information.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun initMainDispatcher() {
|
||||
Dispatchers.setMain(standardTestDispatcher)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.startup.StartupPlugin
|
||||
import at.bitfire.davdroid.startup.TasksAppWatcher
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import dagger.multibindings.Multibinds
|
||||
|
||||
// remove TasksAppWatcherModule from Android tests
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
|
||||
)
|
||||
abstract class TestTasksAppWatcherModule {
|
||||
// provides empty set of plugins
|
||||
@Multibinds
|
||||
abstract fun empty(): Set<StartupPlugin>
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.os.Build
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.ARecord
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.Type
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
|
||||
class Android10ResolverTest {
|
||||
|
||||
val FQDN_DAVX5 = "www.davx5.com"
|
||||
|
||||
@Test
|
||||
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q, maxSdkVersion = 34)
|
||||
fun testResolveA() {
|
||||
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()
|
||||
|
||||
val srvLookup = Lookup(FQDN_DAVX5, Type.A)
|
||||
srvLookup.setResolver(Android10Resolver())
|
||||
val resultGeneric = srvLookup.run()
|
||||
assertEquals(1, resultGeneric.size)
|
||||
|
||||
val result = resultGeneric.first() as ARecord
|
||||
assertEquals(www, result.address)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
|
||||
@HiltAndroidTest
|
||||
class DnsRecordResolverTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var dnsRecordResolver: DnsRecordResolver
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_Empty() {
|
||||
assertNull(dnsRecordResolver.bestSRVRecord(emptyArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_MultipleRecords_Priority_Different() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val dns2010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 20, 20, 8443, Name.fromString("dav2010.example.com.")
|
||||
)
|
||||
|
||||
// lowest priority first
|
||||
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns2010))
|
||||
assertEquals(dns1010, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_MultipleRecords_Priority_Same() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val dns1020 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
|
||||
)
|
||||
val dns1030 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 30, 8443, Name.fromString("dav1030.example.com.")
|
||||
)
|
||||
val records = arrayOf(dns1010, dns1020, dns1030)
|
||||
|
||||
val randomNumberGenerator = mockk<Random>()
|
||||
for (i in 0..60) {
|
||||
every { randomNumberGenerator.nextInt(0, 61) } returns i
|
||||
val expected = when (i) {
|
||||
in 0..10 -> dns1010
|
||||
in 11..30 -> dns1020
|
||||
else -> dns1030
|
||||
}
|
||||
assertEquals(expected, dnsRecordResolver.bestSRVRecord(records, randomNumberGenerator))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBestSRVRecord_OneRecord() {
|
||||
val dns1010 = SRVRecord(
|
||||
Name.fromString("_caldavs._tcp.example.com."),
|
||||
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
|
||||
)
|
||||
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010))
|
||||
assertEquals(dns1010, result)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_Empty() {
|
||||
assertTrue(dnsRecordResolver.pathsFromTXTRecords(arrayOf()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_OnePath() {
|
||||
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
|
||||
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("something=else", "path=/path1"))
|
||||
)).toTypedArray()
|
||||
assertArrayEquals(arrayOf("/path1"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPathsFromTXTRecords_TwoPaths() {
|
||||
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
|
||||
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("path=/path1", "something-else", "path=/path2"))
|
||||
)).toTypedArray()
|
||||
result.sort()
|
||||
assertArrayEquals(arrayOf("/path1", "/path2"), result)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HttpClientTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
lateinit var httpClient: HttpClient
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = httpClientBuilder.build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCookies() {
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
val url = server.url("/test")
|
||||
|
||||
// set cookie for root path (/) and /test path in first response
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
// cookie should be sent with second request
|
||||
// second response lets first cookie expire and overwrites second cookie
|
||||
server.enqueue(MockResponse()
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
val header = server.takeRequest().getHeader("Cookie")
|
||||
assertTrue(header == "cookie1=1; cookie2=2" || header == "cookie2=2; cookie1=1")
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.Request
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class OkhttpClientTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 34)
|
||||
fun testIcloudWithSettings() {
|
||||
httpClientBuilder.build().use { client ->
|
||||
client.okHttpClient
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.get()
|
||||
.url("https://icloud.com")
|
||||
.build()
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PushMessageHandlerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var handler: PushMessageHandler
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testParse_InvalidXml() {
|
||||
Assert.assertNull(handler.parse("Non-XML content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParse_WithXmlDeclAndTopic() {
|
||||
val topic = handler.parse(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
|
||||
"<P:push-message xmlns:D=\"DAV:\" xmlns:P=\"https://bitfire.at/webdav-push\">" +
|
||||
" <P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>" +
|
||||
"</P:push-message>"
|
||||
)
|
||||
Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.test.rule.ServiceTestRule
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.confirmVerified
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltAndroidTest
|
||||
class UnifiedPushServiceTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@get:Rule
|
||||
val serviceTestRule = ServiceTestRule()
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@RelaxedMockK
|
||||
@BindValue
|
||||
lateinit var pushRegistrationManager: PushRegistrationManager
|
||||
|
||||
lateinit var binder: IBinder
|
||||
lateinit var unifiedPushService: UnifiedPushService
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!!
|
||||
unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testOnNewEndpoint() = runTest {
|
||||
val endpoint = mockk<PushEndpoint> {
|
||||
every { url } returns "https://example.com/12"
|
||||
}
|
||||
unifiedPushService.onNewEndpoint(endpoint, "12")
|
||||
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
pushRegistrationManager.processSubscription(12, endpoint)
|
||||
}
|
||||
confirmVerified(pushRegistrationManager)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnRegistrationFailed() = runTest {
|
||||
unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34")
|
||||
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
pushRegistrationManager.removeSubscription(34)
|
||||
}
|
||||
confirmVerified(pushRegistrationManager)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnUnregistered() = runTest {
|
||||
unifiedPushService.onUnregistered("45")
|
||||
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
pushRegistrationManager.removeSubscription(45)
|
||||
}
|
||||
confirmVerified(pushRegistrationManager)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
// System under test
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
// Real injections
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
// Dependency overrides
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var automaticSyncManager: AutomaticSyncManager
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var localAddressBookStore: LocalAddressBookStore
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var localCalendarStore: LocalCalendarStore
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@BindValue @MockK(relaxed = true)
|
||||
lateinit var tasksAppManager: TasksAppManager
|
||||
|
||||
|
||||
// Account setup
|
||||
private val newName = "Renamed Account"
|
||||
lateinit var am: AccountManager
|
||||
lateinit var accountType: String
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
// Account setup
|
||||
am = AccountManager.get(context)
|
||||
accountType = context.getString(R.string.account_type)
|
||||
account = TestAccount.create()
|
||||
|
||||
// AccountsCleanupWorker static mocking
|
||||
mockkObject(AccountsCleanupWorker)
|
||||
every { AccountsCleanupWorker.lockAccountsCleanup() } returns Unit
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
am.getAccountsByType(accountType).forEach { account ->
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
unmockkObject(AccountsCleanupWorker)
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
|
||||
// testRename
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testRename_checksForAlreadyExisting() = runTest {
|
||||
val existing = Account("Existing Account", accountType)
|
||||
am.addAccountExplicitly(existing, null, null)
|
||||
|
||||
accountRepository.rename(account.name, existing.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_locksAccountsCleanup() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
verify { AccountsCleanupWorker.lockAccountsCleanup() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_renamesAccountInAndroid() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val accountsAfter = am.getAccountsByType(accountType)
|
||||
assertTrue(accountsAfter.any { it.name == newName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_cancelsRunningSynchronizationOfOldAccount() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { syncWorkerManager.cancelAllWork(account) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_disablesPeriodicSyncsForOldAccount() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
for (dataType in SyncDataType.entries)
|
||||
coVerify(exactly = 1) {
|
||||
syncWorkerManager.disablePeriodic(account, dataType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAccountNameReferencesInDatabase() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { serviceRepository.renameAccount(account.name, newName) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAddressBooks() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesCalendarEvents() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAccountNameOfLocalTasks() = runTest {
|
||||
val mockDataStore = mockk<LocalDataStore<*>>(relaxed = true)
|
||||
every { tasksAppManager.getDataStore() } returns mockDataStore
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { mockDataStore.updateAccount(account, accountRepository.fromName(newName)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_updatesAutomaticSync() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { automaticSyncManager.updateAutomaticSync(newAccount) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRename_releasesAccountsCleanupWorkerMutex() = runTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
verify { AccountsCleanupWorker.lockAccountsCleanup() }
|
||||
coVerify { serviceRepository.renameAccount(account.name, newName) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookStoreTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var localAddressBookStore: LocalAddressBookStore
|
||||
|
||||
@RelaxedMockK
|
||||
lateinit var provider: ContentProviderClient
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
lateinit var addressBookAccountType: String
|
||||
|
||||
lateinit var addressBookAccount: Account
|
||||
lateinit var account: Account
|
||||
lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
|
||||
account = TestAccount.create()
|
||||
service = Service(
|
||||
id = 200,
|
||||
accountName = account.name,
|
||||
type = Service.Companion.TYPE_CARDDAV,
|
||||
principal = null
|
||||
)
|
||||
db.serviceDao().insertOrReplace(service)
|
||||
addressBookAccount = Account(
|
||||
"MrRobert@example.com",
|
||||
addressBookAccountType
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
removeAddressBooks()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_accountName_removesSpecialChars() {
|
||||
// Should remove iso control characters and `, ", ',
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns "手 M's_\"F-e\"\\(´д`)/;æøå% äöü #42"
|
||||
every { serviceId } returns service.id
|
||||
}
|
||||
assertEquals("手 Ms_F-e\\(´д)/;æøå% äöü #42 (Test Account) #1", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingService() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayName() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 42
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns service.id
|
||||
}
|
||||
val accountName = localAddressBookStore.accountName(collection)
|
||||
assertEquals("funnyfriends (${account.name}) #42", accountName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_accountName_missingDisplayNameAndService() {
|
||||
val collection = mockk<Collection> {
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { displayName } returns null
|
||||
every { serviceId } returns 404 // missing service
|
||||
}
|
||||
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_create_createAccountReturnsNull() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns service.id
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
}
|
||||
|
||||
mockkObject(localAddressBookStore)
|
||||
every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null
|
||||
|
||||
assertEquals(null, localAddressBookStore.create(provider, collection))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_create_ReadOnly() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns service.id
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { readOnly() } returns true
|
||||
}
|
||||
val addrBook = localAddressBookStore.create(provider, collection)!!
|
||||
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
|
||||
assertTrue(addrBook.readOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_create_ReadWrite() {
|
||||
val collection = mockk<Collection>(relaxed = true) {
|
||||
every { serviceId } returns service.id
|
||||
every { id } returns 1
|
||||
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
|
||||
every { readOnly() } returns false
|
||||
}
|
||||
|
||||
val addrBook = localAddressBookStore.create(provider, collection)!!
|
||||
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
|
||||
assertFalse(addrBook.readOnly)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_getAll_differentAccount() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns "Another Unrelated Account"
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
|
||||
val result = localAddressBookStore.getAll(account, provider)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getAll_sameAccount() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns account.name
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
|
||||
val result = localAddressBookStore.getAll(account, provider)
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(addressBookAccount, result.first().addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests the calculation of read only state is correct
|
||||
*/
|
||||
@Test
|
||||
fun test_shouldBeReadOnly() {
|
||||
val collectionReadOnly = mockk<Collection> { every { readOnly() } returns true }
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true))
|
||||
|
||||
val collectionNotReadOnly = mockk<Collection> { every { readOnly() } returns false }
|
||||
assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false))
|
||||
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun removeAddressBooks() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(addressBookAccountType).forEach {
|
||||
accountManager.removeAccountExplicitly(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.LabeledProperty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests whether contacts are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsContacts() {
|
||||
localTestAddressBookProvider.provide(account, provider) { addressBook ->
|
||||
// insert contact with data row
|
||||
val uid = "12345"
|
||||
val contact = Contact(
|
||||
uid = uid,
|
||||
displayName = "Test Contact",
|
||||
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
|
||||
)
|
||||
val uri = LocalContact(addressBook, contact, null, null, 0).add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
val localContact = addressBook.findContactById(id)
|
||||
localContact.resetDirty()
|
||||
assertFalse("Contact is dirty before moving", isContactDirty(addressBook, id))
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// check whether contact is still here (including data rows) and not dirty
|
||||
val result = addressBook.findContactById(id)
|
||||
assertFalse("Contact is dirty after moving", isContactDirty(addressBook, id))
|
||||
|
||||
val contact2 = result.getContact()
|
||||
assertEquals(uid, contact2.uid)
|
||||
assertEquals("Test Contact", contact2.displayName)
|
||||
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether groups are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsGroups() {
|
||||
localTestAddressBookProvider.provide(account, provider) { addressBook ->
|
||||
// insert group
|
||||
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
|
||||
val uri = localGroup.add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(Optional.empty(), null, null)
|
||||
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
assertTrue(addressBook.renameAccount(newName))
|
||||
assertEquals(newName, addressBook.addressBookAccount.name)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", isGroupDirty(addressBook, id))
|
||||
|
||||
val group = result.getContact()
|
||||
assertEquals("Test Group", group.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact.
|
||||
*
|
||||
* @return true if the contact is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the contact can't be found
|
||||
*/
|
||||
fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id)
|
||||
provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact group.
|
||||
*
|
||||
* @return true if the group is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the group can't be found
|
||||
*/
|
||||
fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
|
||||
provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Entity
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.test.InitCalendarProviderRule
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalCalendarTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
|
||||
|
||||
@Inject
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var androidCalendar: AndroidCalendar
|
||||
private lateinit var client: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
androidCalendar = provider.createAndGetCalendar(ContentValues())
|
||||
calendar = localCalendarFactory.create(androidCalendar)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
androidCalendar.delete()
|
||||
client.closeCompat()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
// Needs InitCalendarProviderRule
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventUrl = androidCalendar.eventUri(localEvent.id)
|
||||
|
||||
// set event as dirty
|
||||
client.update(eventUrl, contentValuesOf(
|
||||
Events.DIRTY to 1
|
||||
), null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events._ID]
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
calendar.removeNotDirtyMarked(123)
|
||||
|
||||
assertNull(androidCalendar.getEvent(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies that [LocalCalendar.markNotDirty] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testMarkNotDirty(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events._ID to 1,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
val updated = calendar.markNotDirty(321)
|
||||
assertEquals(1, updated)
|
||||
assertEquals(321, androidCalendar.getEvent(id)?.flags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to 0
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to null
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalEventTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
@Inject
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var client: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.androidCalendar.delete()
|
||||
client.closeCompat()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NoUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage should be the same as file name
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NormalUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
assertEquals(event.uid, fileName)
|
||||
|
||||
// UID in calendar storage should still be set, too
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_UidHasDangerousChars() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage shouldn't have been changed
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(event.uid, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
lateinit var provider: ContentProviderClient
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
// set pending membership of contact1
|
||||
ab.provider!!.update(
|
||||
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
|
||||
ContentValues().apply {
|
||||
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
|
||||
},
|
||||
null, null
|
||||
)
|
||||
|
||||
// pending membership -> contact1 should be added to group
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_removeMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
|
||||
// add contact1 to group
|
||||
val batch = ContactsBatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
// no pending memberships -> membership should be removed
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDirty_addCachedGroupMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 =
|
||||
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert group membership, but no cached group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(GroupMembership.GROUP_ROW_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDirty_removeCachedGroupMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert cached group membership, but no group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(CachedGroupMembership.GROUP_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMarkMembersDirty() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 =
|
||||
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val batch = ContactsBatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
assertEquals(0, ab.findDirty().size)
|
||||
group.markMembersDirty()
|
||||
assertEquals(contact1.id, ab.findDirty().first().id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
val group = newGroup(ab)
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdate() {
|
||||
localTestAddressBookProvider.provide(account, provider) { ab ->
|
||||
val group = newGroup(ab)
|
||||
group.update(Contact(displayName = "New Group Name"), null, null, null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* A local address book that provides an easy way to set the group method in tests.
|
||||
*/
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted("addressBook") addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository,
|
||||
syncFramework: SyncFrameworkIntegration
|
||||
): LocalAddressBook(
|
||||
account = account,
|
||||
_addressBookAccount = addressBookAccount,
|
||||
provider = provider,
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
context = context,
|
||||
dirtyVerifier = Optional.empty(),
|
||||
logger = logger,
|
||||
serviceRepository = serviceRepository,
|
||||
syncFramework = syncFramework
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
@Assisted("addressBook") addressBookAccount: Account,
|
||||
provider: ContentProviderClient,
|
||||
groupMethod: GroupMethod
|
||||
): LocalTestAddressBook
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Provides [LocalTestAddressBook]s in tests.
|
||||
*/
|
||||
class LocalTestAddressBookProvider @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val localTestAddressBookFactory: LocalTestAddressBook.Factory
|
||||
) {
|
||||
|
||||
/**
|
||||
* Counter for creating unique address book names.
|
||||
*/
|
||||
val counter = AtomicInteger()
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val accountType = context.getString(R.string.account_type_address_book)
|
||||
|
||||
/**
|
||||
* Creates and provides a new temporary [LocalTestAddressBook] for the given [account] and
|
||||
* removes it again.
|
||||
*
|
||||
* @param account The DAVx5 account to use for the address book
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
* @param groupMethod The group method the address book should use
|
||||
* @param block Function to execute with the temporary available address book
|
||||
*/
|
||||
fun provide(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS,
|
||||
block: (LocalTestAddressBook) -> Unit
|
||||
) {
|
||||
// create new address book account
|
||||
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", accountType)
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
val addressBook = localTestAddressBookFactory.create(account, addressBookAccount, provider, groupMethod)
|
||||
|
||||
// Empty the address book (Needed by LocalGroupTest)
|
||||
for (contact in addressBook.queryContacts(null, null))
|
||||
contact.delete()
|
||||
for (group in addressBook.queryGroups(null, null))
|
||||
group.delete()
|
||||
|
||||
try {
|
||||
// provide address book
|
||||
block(addressBook)
|
||||
} finally {
|
||||
// recreate account of provided address book, since the account might have been renamed
|
||||
val renamedAccount = Account(addressBook.addressBookAccount.name, addressBook.addressBookAccount.type)
|
||||
|
||||
// remove address book account / address book
|
||||
assertTrue(accountManager.removeAccountExplicitly(renamedAccount))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CachedGroupMembershipHandlerTest {
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook ->
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 123456)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class GroupMembershipBuilderTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsCategories() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsVCards() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class GroupMembershipHandlerTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val account = Account("Test Account", "Test Account Type")
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UnknownPropertiesBuilderTest {
|
||||
|
||||
@Test
|
||||
fun testUnknownProperties_None() {
|
||||
UnknownPropertiesBuilder(Uri.EMPTY, null, Contact(), false).build().also { result ->
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnknownProperties_Properties() {
|
||||
UnknownPropertiesBuilder(Uri.EMPTY, null, Contact().apply {
|
||||
unknownProperties = "X-TEST:12345"
|
||||
}, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(UnknownProperties.CONTENT_ITEM_TYPE, result[0].values[UnknownProperties.MIMETYPE])
|
||||
assertEquals("X-TEST:12345", result[0].values[UnknownProperties.UNKNOWN_PROPERTIES])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class UnknownPropertiesHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testUnknownProperties_Empty() {
|
||||
val contact = Contact()
|
||||
UnknownPropertiesHandler.handle(ContentValues().apply {
|
||||
putNull(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
}, contact)
|
||||
assertNull(contact.unknownProperties)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnknownProperties_Values() {
|
||||
val contact = Contact()
|
||||
UnknownPropertiesHandler.handle(ContentValues().apply {
|
||||
put(UnknownProperties.UNKNOWN_PROPERTIES, "X-TEST:12345")
|
||||
}, contact)
|
||||
assertEquals("X-TEST:12345", contact.unknownProperties)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionsWithoutHomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshCollectionsWithoutHomeSet
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("${request.method} on $path")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavResourceFinderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_NO_DAV = "/nodav"
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(host = null, getCredentials = { credentials })
|
||||
.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
val baseURI = URI.create("/")
|
||||
finder = resourceFinderFactory.create(baseURI, credentials)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
assertEquals(0, info.homeSets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProvidesService() {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetCurrentUserPrincipal() {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryEmailAddress() {
|
||||
var info = ServiceInfo()
|
||||
assertArrayEquals(
|
||||
arrayOf("email1@example.com", "email2@example.com"),
|
||||
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
|
||||
)
|
||||
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(request)) {
|
||||
val authenticate = MockResponse().setResponseCode(401)
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
|
||||
return authenticate
|
||||
}
|
||||
|
||||
val path = request.path!!
|
||||
|
||||
if (request.method.equals("OPTIONS", true)) {
|
||||
val dav = when {
|
||||
path.startsWith(PATH_CALDAV) -> "calendar-access"
|
||||
path.startsWith(PATH_CARDDAV) -> "addressbook"
|
||||
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
|
||||
else -> null
|
||||
}
|
||||
val response = MockResponse().setResponseCode(200)
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav)
|
||||
return response
|
||||
} else if (request.method.equals("PROPFIND", true)) {
|
||||
val props: String?
|
||||
when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
logger.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${request.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
private fun checkAuth(rq: RecordedRequest) =
|
||||
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// other
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PrincipalsRefresherTest {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var principalsRefresher: PrincipalsRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" + "<CARD:addressbook-home-set>" + " <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" + "</CARD:addressbook-home-set>" + "<group-membership>" + " <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class ServiceRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
serviceRefresherFactory.create(service, client.okHttpClient)
|
||||
.discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("Query: ${request.method} on $path ")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context)
|
||||
}
|
||||
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testUpdate_MissingMigrations() {
|
||||
TestAccount.provide(version = 1) { account ->
|
||||
// will run AccountSettings.update
|
||||
accountSettingsFactory.create(account, abortOnMissingMigration = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdate_RunAllMigrations() {
|
||||
TestAccount.provide(version = 6) { account ->
|
||||
// will run AccountSettings.update
|
||||
accountSettingsFactory.create(account, abortOnMissingMigration = true)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val version = accountManager.getUserData(account, AccountSettings.KEY_SETTINGS_VERSION).toInt()
|
||||
assertEquals(AccountSettings.CURRENT_VERSION, version)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toSet
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SettingsManagerTest {
|
||||
|
||||
companion object {
|
||||
/** Use this setting to test SettingsManager methods. Will be removed after every test run. */
|
||||
const val SETTING_TEST = "test"
|
||||
}
|
||||
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Inject lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeTestSetting() {
|
||||
settingsManager.remove(SETTING_TEST)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_containsKey_NotExisting() {
|
||||
assertFalse(settingsManager.containsKey("notExisting"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_containsKey_Existing() {
|
||||
// provided by DefaultsProvider
|
||||
assertEquals(Settings.PROXY_TYPE_SYSTEM, settingsManager.getInt(Settings.PROXY_TYPE))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun test_observerFlow_initialValue() = runTest {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
if (counter++ == 0)
|
||||
23
|
||||
else
|
||||
throw AssertionError("A second value was requested")
|
||||
}
|
||||
assertEquals(23, live.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_observerFlow_updatedValue() = runTest {
|
||||
var counter = 0
|
||||
val live = settingsManager.observerFlow {
|
||||
when (counter++) {
|
||||
0 -> {
|
||||
// update some setting so that we will be called a second time
|
||||
settingsManager.putBoolean(SETTING_TEST, true)
|
||||
// and emit initial value
|
||||
23
|
||||
}
|
||||
1 -> 42 // updated value
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
|
||||
val result = live.take(2).toSet()
|
||||
assertEquals(setOf(23, 42), result)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration17Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration17
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS, android.Manifest.permission.WRITE_CONTACTS)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_OldAddressBook_CollectionInDB() {
|
||||
val localAddressBookUserDataUrl = "url"
|
||||
TestAccount.provide(version = 16) { account ->
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
try {
|
||||
// address book has account + URL
|
||||
val url = "https://example.com/address-book"
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url)
|
||||
|
||||
// and is known in database
|
||||
db.serviceDao().insertOrReplace(
|
||||
Service(
|
||||
id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null
|
||||
)
|
||||
)
|
||||
db.collectionDao().insert(
|
||||
Collection(
|
||||
id = 100,
|
||||
serviceId = 1,
|
||||
url = url.toHttpUrl(),
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
displayName = "Some Address Book"
|
||||
)
|
||||
)
|
||||
|
||||
// run migration
|
||||
migration.migrate(account)
|
||||
|
||||
// migration renames address book, update account
|
||||
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
|
||||
accountManager.getUserData(it, localAddressBookUserDataUrl) == url
|
||||
}.first()
|
||||
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)
|
||||
|
||||
// ID is now assigned
|
||||
assertEquals(100L, accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLong())
|
||||
} finally {
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration18Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration18
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_InvalidCollection() {
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
|
||||
|
||||
val account = Account("test", "test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify(exactly = 0) {
|
||||
accountManager.setUserData(addressBookAccount, any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_NoCollection() {
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
|
||||
|
||||
val account = Account("test", "test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify(exactly = 0) {
|
||||
accountManager.setUserData(addressBookAccount, any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrate_AddressBook_ValidCollection() {
|
||||
val account = Account("test", "test")
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(
|
||||
id = 10,
|
||||
accountName = account.name,
|
||||
type = Service.TYPE_CARDDAV,
|
||||
principal = null
|
||||
))
|
||||
db.collectionDao().insertOrUpdateByUrl(Collection(
|
||||
id = 100,
|
||||
serviceId = 10,
|
||||
url = "http://example.com".toHttpUrl(),
|
||||
type = Collection.TYPE_ADDRESSBOOK
|
||||
))
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
var addressBookAccount = Account("Address Book", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
mockkObject(accountManager)
|
||||
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
|
||||
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "100"
|
||||
|
||||
migration.migrate(account)
|
||||
|
||||
verify {
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.sync.AutomaticSyncManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration19Test {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var automaticSyncManager: AutomaticSyncManager
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration19
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrate_CancelsOldWorkersAndUpdatesAutomaticSync() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
val account = Account("Some", "Test")
|
||||
migration.migrate(account)
|
||||
|
||||
verify {
|
||||
workManager.cancelUniqueWork("periodic-sync at.bitfire.davdroid.addressbooks Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync com.android.calendar Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync at.techbee.jtx.provider Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync org.dmfs.tasks Test/Some")
|
||||
workManager.cancelUniqueWork("periodic-sync org.tasks.opentasks Test/Some")
|
||||
|
||||
automaticSyncManager.updateAutomaticSync(account)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.database.getLongOrNull
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCalendarStore
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountSettingsMigration20Test {
|
||||
|
||||
@Inject
|
||||
lateinit var calendarStore: LocalCalendarStore
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var migration: AccountSettingsMigration20
|
||||
|
||||
@Inject
|
||||
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionsRule = GrantPermissionRule.grant(
|
||||
Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
|
||||
)
|
||||
|
||||
val accountManager by lazy { AccountManager.get(context) }
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrateAddressBooks_UrlMatchesCollection() {
|
||||
// set up legacy address-book with URL, but without collection ID
|
||||
val account = Account("test", "test")
|
||||
val url = "https://example.com/"
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null))
|
||||
val collectionId = db.collectionDao().insert(Collection(
|
||||
serviceId = 1,
|
||||
type = Collection.Companion.TYPE_ADDRESSBOOK,
|
||||
url = url.toHttpUrl()
|
||||
))
|
||||
|
||||
localTestAddressBookProvider.provide(account, mockk(relaxed = true), GroupMethod.GROUP_VCARDS) { addressBook ->
|
||||
|
||||
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
|
||||
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
|
||||
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, AccountSettingsMigration20.ADDRESS_BOOK_USER_DATA_URL, url)
|
||||
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, null)
|
||||
|
||||
migration.migrateAddressBooks(account, cardDavServiceId = 1)
|
||||
|
||||
assertEquals(
|
||||
collectionId,
|
||||
accountManager.getUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID).toLongOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMigrateCalendars_UrlMatchesCollection() {
|
||||
// set up legacy calendar with URL, but without collection ID
|
||||
val account = Account("test", CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
val url = "https://example.com/"
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null))
|
||||
val collectionId = db.collectionDao().insert(
|
||||
Collection(
|
||||
serviceId = 1,
|
||||
type = Collection.Companion.TYPE_CALENDAR,
|
||||
url = url.toHttpUrl()
|
||||
)
|
||||
)
|
||||
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!.use { provider ->
|
||||
val uri = provider.insert(
|
||||
Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
contentValuesOf(
|
||||
Calendars.ACCOUNT_NAME to account.name,
|
||||
Calendars.ACCOUNT_TYPE to account.type,
|
||||
Calendars.CALENDAR_DISPLAY_NAME to "Test",
|
||||
Calendars.NAME to url,
|
||||
Calendars.SYNC_EVENTS to 1
|
||||
)
|
||||
)!!.asSyncAdapter(account)
|
||||
try {
|
||||
migration.migrateCalendars(account, 1)
|
||||
|
||||
provider.query(uri, arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(collectionId, cursor.getLongOrNull(0))
|
||||
}
|
||||
} finally {
|
||||
provider.delete(uri, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.SyncRequest
|
||||
import android.content.SyncStatusObserver
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltAndroidTest
|
||||
class AndroidSyncFrameworkTest: SyncStatusObserver {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
lateinit var account: Account
|
||||
val authority = CalendarContract.AUTHORITY
|
||||
|
||||
private lateinit var stateChangeListener: Any
|
||||
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// Enable sync globally and for the test account
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// Remember states the sync framework reports as pairs of (sync pending, sync active).
|
||||
recordedStates.clear()
|
||||
onStatusChanged(0) // record first entry (pending = false, active = false)
|
||||
stateChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.removeStatusChangeListener(stateChangeListener)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Correct behaviour of the sync framework on Android 13 and below.
|
||||
* Pending state is correctly reflected
|
||||
*/
|
||||
@SdkSuppress(maxSdkVersion = 33)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = false, active = true), // ... and then only active
|
||||
State(pending = false, active = false) // sync finished
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrong behaviour of the sync framework on Android 14+.
|
||||
* Pending state stays true forever (after initial run), active state behaves correctly
|
||||
*/
|
||||
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = true, active = false) // ... and finishes, but stays pending
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncRequest() = SyncRequest.Builder()
|
||||
.setSyncAdapter(account, authority)
|
||||
.syncOnce()
|
||||
.setExtras(Bundle()) // needed for Android 9
|
||||
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
|
||||
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Verifies that the given expected states match the recorded states.
|
||||
*/
|
||||
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
|
||||
// Verify that last state is non-optional.
|
||||
if (expectedStates.last().optional)
|
||||
throw IllegalArgumentException("Last expected state must not be optional")
|
||||
|
||||
// We use runBlocking for these tests because it uses the default dispatcher
|
||||
// which does not auto-advance virtual time and we need real system time to
|
||||
// test the sync framework behavior.
|
||||
|
||||
ContentResolver.requestSync(syncRequest())
|
||||
|
||||
// Even though the always-pending-bug is present on Android 14+, the sync active
|
||||
// state behaves correctly, so we can record the state changes as pairs (pending,
|
||||
// active) and expect a certain sequence of state pairs to verify the presence or
|
||||
// absence of the bug on different Android versions.
|
||||
withTimeout(60.seconds) { // Usually takes less than 30 seconds
|
||||
while (recordedStates.size < expectedStates.size) {
|
||||
// verify already known states
|
||||
if (recordedStates.isNotEmpty())
|
||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = false)
|
||||
|
||||
delay(500) // avoid busy-waiting
|
||||
}
|
||||
|
||||
assertStatesEqual(expectedStates, recordedStates, fullMatch = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean) {
|
||||
assertTrue("Expected states=$expectedStates, actual=$actualStates", statesMatch(expectedStates, actualStates, fullMatch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether [actualStates] have matching [expectedStates], under the condition
|
||||
* that expected states with the [State.optional] flag can be skipped.
|
||||
*
|
||||
* Note: When [fullMatch] is not set, this method can return _true_ even if not all expected states are used.
|
||||
*
|
||||
* @param expectedStates expected states (can include optional states which don't have to be present in actual states)
|
||||
* @param actualStates actual states
|
||||
* @param fullMatch whether all non-optional expected states must be present in actual states
|
||||
*/
|
||||
private fun statesMatch(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean): Boolean {
|
||||
// iterate through entries
|
||||
val expectedIterator = expectedStates.iterator()
|
||||
for (actual in actualStates) {
|
||||
if (!expectedIterator.hasNext())
|
||||
return false
|
||||
var expected = expectedIterator.next()
|
||||
|
||||
// skip optional expected entries if they don't match the actual entry
|
||||
while (!actual.stateEquals(expected) && expected.optional) {
|
||||
if (!expectedIterator.hasNext())
|
||||
return false
|
||||
expected = expectedIterator.next()
|
||||
}
|
||||
|
||||
// we now have a non-optional expected state and it must match
|
||||
if (!actual.stateEquals(expected))
|
||||
return false
|
||||
}
|
||||
|
||||
// full match: all expected states must have been used
|
||||
if (fullMatch && expectedIterator.hasNext())
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// SyncStatusObserver implementation and data class
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
val state = State(
|
||||
pending = ContentResolver.isSyncPending(account, authority),
|
||||
active = ContentResolver.isSyncActive(account, authority)
|
||||
)
|
||||
synchronized(recordedStates) {
|
||||
if (recordedStates.lastOrNull() != state) {
|
||||
logger.info("$account syncState = $state")
|
||||
recordedStates += state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
val pending: Boolean,
|
||||
val active: Boolean,
|
||||
val optional: Boolean = false
|
||||
) {
|
||||
fun stateEquals(other: State) =
|
||||
pending == other.pending && active == other.active
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
var globalAutoSyncBeforeTest = false
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun before() {
|
||||
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
|
||||
ContentResolver.setMasterSyncAutomatically(false)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun after() {
|
||||
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class FakeSyncAdapter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger
|
||||
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
|
||||
|
||||
init {
|
||||
logger.info("FakeSyncAdapter created")
|
||||
}
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
logger.log(
|
||||
Level.INFO,
|
||||
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
|
||||
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
|
||||
)
|
||||
|
||||
// fake 5 sec sync
|
||||
try {
|
||||
Thread.sleep(5000)
|
||||
} catch (_: InterruptedException) {
|
||||
logger.info("onPerformSync($account) cancelled")
|
||||
}
|
||||
|
||||
logger.info("onPerformSync($account) finished")
|
||||
}
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.test.GrantPermissionOrSkipRule
|
||||
import at.techbee.jtx.JtxContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeNotNull
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.StringReader
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
/**
|
||||
* Ensure you have jtxBoard installed on the emulator, before running these tests. Otherwise they
|
||||
* will be skipped.
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class JtxSyncManagerTest {
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var localJtxCollectionStore: LocalJtxCollectionStore
|
||||
|
||||
@Inject
|
||||
lateinit var jtxSyncManagerFactory: JtxSyncManager.Factory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet())
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var syncManager: JtxSyncManager
|
||||
private lateinit var localJtxCollection: LocalJtxCollection
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Check jtxBoard permissions were granted (+jtxBoard is installed); skip test otherwise
|
||||
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))
|
||||
|
||||
// Acquire the jtx content provider
|
||||
val providerOrNull = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
|
||||
assumeNotNull(providerOrNull)
|
||||
provider = providerOrNull!!
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// Create dummy dependencies
|
||||
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
|
||||
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
|
||||
val dbCollection = Collection(
|
||||
0,
|
||||
serviceId,
|
||||
type = Collection.TYPE_CALENDAR,
|
||||
url = "https://example.com".toHttpUrl()
|
||||
)
|
||||
localJtxCollection = localJtxCollectionStore.create(provider, dbCollection)!!
|
||||
syncManager = jtxSyncManagerFactory.jtxSyncManager(
|
||||
account = account,
|
||||
httpClient = httpClientBuilder.build(),
|
||||
syncResult = SyncResult(),
|
||||
localCollection = localJtxCollection,
|
||||
collection = dbCollection,
|
||||
resync = null
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
if (this::localJtxCollection.isInitialized)
|
||||
localJtxCollectionStore.delete(localJtxCollection)
|
||||
serviceRepository.deleteAllBlocking()
|
||||
|
||||
if (this::provider.isInitialized)
|
||||
provider.closeCompat()
|
||||
|
||||
if (this::account.isInitialized)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testProcessICalObject_addsVtodo() {
|
||||
val calendar = "BEGIN:VCALENDAR\n" +
|
||||
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
|
||||
"VERSION:2.0\n" +
|
||||
"BEGIN:VTODO\n" +
|
||||
"SUMMARY:Test Task (Main VTODO)\n" +
|
||||
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
|
||||
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
|
||||
"END:VTODO\n" +
|
||||
"END:VCALENDAR"
|
||||
|
||||
// Should create "demo-calendar"
|
||||
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
|
||||
|
||||
// Verify main VTODO is created
|
||||
val localJtxIcalObject = localJtxCollection.findByName("demo-calendar")!!
|
||||
assertEquals("47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", localJtxIcalObject.uid)
|
||||
assertEquals("abc123", localJtxIcalObject.eTag)
|
||||
assertEquals("Test Task (Main VTODO)", localJtxIcalObject.summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProcessICalObject_addsRecurringVtodo_withoutDtStart() {
|
||||
// Valid calendar example (See bitfireAT/davx5-ose#1265)
|
||||
// Note: We don't support starting a recurrence from DUE (RFC 5545 leaves it open to interpretation)
|
||||
val calendar = "BEGIN:VCALENDAR\n" +
|
||||
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
|
||||
"VERSION:2.0\n" +
|
||||
"BEGIN:VTODO\n" +
|
||||
|
||||
"SUMMARY:Test Task (Exception)\n" +
|
||||
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
|
||||
"DUE;TZID=America/New_York:20250228T130000\n" +
|
||||
"RECURRENCE-ID;TZID=America/New_York:20250228T130000\n" +
|
||||
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
|
||||
|
||||
"END:VTODO\n" +
|
||||
"BEGIN:VTODO\n" +
|
||||
|
||||
"SUMMARY:Test Task (Main VTODO)\n" +
|
||||
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
|
||||
"DUE;TZID=America/New_York:20250228T130000\n" + // Due date will NOT be assumed as start for recurrence
|
||||
"SEQUENCE:1\n" +
|
||||
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20250505T235959Z\n" +
|
||||
|
||||
"END:VTODO\n" +
|
||||
"END:VCALENDAR"
|
||||
|
||||
// Create and store calendar
|
||||
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
|
||||
|
||||
// Verify main VTODO was created with RRULE present
|
||||
val mainVtodo = localJtxCollection.findByName("demo-calendar")!!
|
||||
assertEquals("Test Task (Main VTODO)", mainVtodo.summary)
|
||||
assertEquals("FREQ=WEEKLY;UNTIL=20250505T235959Z;INTERVAL=1;BYDAY=FR", mainVtodo.rrule)
|
||||
|
||||
// Verify the RRULE exception instance was created with correct recurrence-id timezone
|
||||
val vtodoException = localJtxCollection.findRecurInstance(
|
||||
uid = "47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f",
|
||||
recurid = "20250228T130000"
|
||||
)!!
|
||||
assertEquals("Test Task (Exception)", vtodoException.summary)
|
||||
assertEquals("America/New_York", vtodoException.recuridTimezone)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
|
||||
class LocalTestCollection(
|
||||
override val dbCollectionId: Long = 0L
|
||||
): LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
override val title = "Local Test Collection"
|
||||
|
||||
override var lastSyncState: SyncState? = null
|
||||
|
||||
val entries = mutableListOf<LocalTestResource>()
|
||||
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
override fun findByName(name: String) = entries.firstOrNull { it.fileName == name }
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
var updated = 0
|
||||
for (dirty in findDirty()) {
|
||||
dirty.flags = flags
|
||||
updated++
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
val numBefore = entries.size
|
||||
entries.removeIf { !it.dirty && it.flags == flags }
|
||||
return numBefore - entries.size
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import java.util.Optional
|
||||
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
|
||||
override val id: Long? = null
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
override var scheduleTag: String? = null
|
||||
override var flags: Int = 0
|
||||
|
||||
var deleted = false
|
||||
var dirty = false
|
||||
|
||||
override fun prepareForUpload() = "generated-file.txt"
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
dirty = false
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
|
||||
override fun deleteLocal() = throw NotImplementedError()
|
||||
override fun resetDeleted() = throw NotImplementedError()
|
||||
|
||||
override fun getDebugSummary() = "Test Resource"
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterImplTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
|
||||
|
||||
@BindValue @MockK
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
val sync = launch {
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
|
||||
// simulate incoming cancellation from sync framework
|
||||
syncAdapter.onSyncCanceled()
|
||||
|
||||
// wait for sync to finish (should happen immediately)
|
||||
sync.join()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
|
||||
// immediate timeout (instead of really waiting)
|
||||
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
|
||||
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker immediately returns with success
|
||||
val success = mockk<WorkInfo>()
|
||||
every { success.state } returns WorkInfo.State.SUCCEEDED
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
|
||||
emit(listOf(success))
|
||||
delay(60000) // keep the flow active
|
||||
}
|
||||
|
||||
// should just run
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.assertWithin
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var syncStatsRepository: DavSyncStatsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
server = MockWebServer().apply {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
||||
private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse {
|
||||
val body = StringBuilder()
|
||||
body.append(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
"<multistatus xmlns=\"DAV:\" xmlns:CALDAV=\"http://calendarserver.org/ns/\">\n" +
|
||||
" <response>\n" +
|
||||
" <href>/</href>\n" +
|
||||
" <propstat>\n" +
|
||||
" <prop>\n"
|
||||
)
|
||||
if (cTag != null)
|
||||
body.append("<CALDAV:getctag>$cTag</CALDAV:getctag>\n")
|
||||
body.append(
|
||||
" </prop>\n" +
|
||||
" </propstat>\n" +
|
||||
" </response>\n" +
|
||||
"</multistatus>"
|
||||
)
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml")
|
||||
.setBody(body.toString())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPerformSync_503RetryAfter_DelaySeconds() = runTest {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(503)
|
||||
.setHeader("Retry-After", "60")) // 60 seconds
|
||||
|
||||
val result = SyncResult()
|
||||
val syncManager = syncManager(LocalTestCollection(), result)
|
||||
syncManager.performSync()
|
||||
|
||||
val expected = Instant.now()
|
||||
.plusSeconds(60)
|
||||
.toEpochMilli()
|
||||
// 5 sec tolerance for test
|
||||
assertWithin(expected, result.delayUntil*1000, 5000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_FirstSync_Empty() = runTest {
|
||||
val collection = LocalTestCollection() /* no last known ctag */
|
||||
server.enqueue(queryCapabilitiesResponse())
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(204)
|
||||
.setHeader("ETag", "etag-from-put"))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/generated-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"etag-from-put\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-put", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "old-etag-like-on-server"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(204)
|
||||
.addHeader("ETag", "etag-from-put"))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("etag-from-put")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-put"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-put", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "old-etag-like-on-server"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("etag-from-propfind")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-propfind"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-propfind", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "etag-that-has-been-changed-on-server-in-the-meanwhile"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 412 Precondition Failed
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(412))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("changed-etag-from-server")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "changed-etag-from-server"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("changed-etag-from-server", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
eTag = "MemberETag1"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/downloaded-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"MemberETag1\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("MemberETag1", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadNewMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/new-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"NewMemberETag1\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/new-member.txt"), "NewMemberETag1"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("NewMemberETag1", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadUpdatedMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
eTag = "MemberETag1"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/downloaded-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"MemberETag2\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/downloaded-member.txt"), "MemberETag2"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("MemberETag2", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_RemoveVanishedMember() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_CTagDidntChange() = runTest {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertFalse(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncManager(
|
||||
localCollection: LocalTestCollection,
|
||||
syncResult: SyncResult = SyncResult(),
|
||||
collection: Collection = mockk<Collection>(relaxed = true) {
|
||||
every { id } returns 1
|
||||
every { url } returns server.url("/")
|
||||
}
|
||||
) = syncManagerFactory.create(
|
||||
account,
|
||||
httpClientBuilder.build(),
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.resource.LocalDataStore
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.impl.annotations.SpyK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@RelaxedMockK
|
||||
lateinit var logger: Logger
|
||||
|
||||
val dataStore: LocalTestStore = mockk(relaxed = true)
|
||||
val provider: ContentProviderClient = mockk(relaxed = true)
|
||||
|
||||
@SpyK
|
||||
@InjectMockKs
|
||||
var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_fails() {
|
||||
every { syncer.prepare(provider) } returns false
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should stop the sync after prepare returns false
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_succeeds() {
|
||||
every { syncer.prepare(provider) } returns true
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should continue the sync after prepare returns true
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_deletesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection> {
|
||||
every { dbCollectionId } returns 0L
|
||||
every { title } returns "Collection to be deleted locally"
|
||||
}
|
||||
|
||||
// Should delete the localCollection if dbCollection (remote) does not exist
|
||||
val localCollections = mutableListOf(localCollection)
|
||||
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
|
||||
verify(exactly = 1) { dataStore.delete(localCollection) }
|
||||
|
||||
// Updated local collection list should be empty
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_updatesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection> {
|
||||
every { dbCollectionId } returns 0L
|
||||
every { title } returns "The Local Collection"
|
||||
}
|
||||
val dbCollection = mockk<Collection> {
|
||||
every { id } returns 0L
|
||||
}
|
||||
val dbCollections = mapOf(0L to dbCollection)
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) }
|
||||
|
||||
// Updated local collection list should be same as input
|
||||
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
val dbCollection = mockk<Collection> {
|
||||
every { id } returns 0L
|
||||
}
|
||||
val localCollections = listOf(mockk<LocalTestCollection> {
|
||||
every { dbCollectionId } returns 0L
|
||||
})
|
||||
val dbCollections = listOf(dbCollection)
|
||||
val dbCollectionsMap = mapOf(dbCollection.id to dbCollection)
|
||||
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(dbCollection.id, result[0].dbCollectionId)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCreateLocalCollections() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dataStore.create(provider, dbCollection) } returns localCollection
|
||||
|
||||
// Should return list of newly created local collections
|
||||
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
|
||||
assertEquals(listOf(localCollection), result)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncCollectionContents() {
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
0L to dbCollection1,
|
||||
1L to dbCollection2
|
||||
)
|
||||
val localCollection1 = mockk<LocalTestCollection> { every { dbCollectionId } returns 0L }
|
||||
val localCollection2 = mockk<LocalTestCollection> { every { dbCollectionId } returns 1L }
|
||||
val localCollections = listOf(localCollection1, localCollection2)
|
||||
every { localCollection1.dbCollectionId } returns 0L
|
||||
every { localCollection2.dbCollectionId } returns 1L
|
||||
every { syncer.syncCollection(provider, any(), any()) } just runs
|
||||
|
||||
// Should call the collection content sync on both collections
|
||||
syncer.syncCollectionContents(provider, localCollections, dbCollections)
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
|
||||
}
|
||||
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer(
|
||||
account: Account,
|
||||
resyncType: ResyncType?,
|
||||
syncResult: SyncResult,
|
||||
theDataStore: LocalTestStore
|
||||
) : Syncer<LocalTestStore, LocalTestCollection>(account, resyncType, syncResult) {
|
||||
|
||||
override val dataStore: LocalTestStore =
|
||||
theDataStore
|
||||
|
||||
override val serviceType: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean =
|
||||
throw NotImplementedError()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
throw NotImplementedError()
|
||||
|
||||
override fun syncCollection(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
remoteCollection: Collection
|
||||
) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LocalTestStore : LocalDataStore<LocalTestCollection> {
|
||||
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
): LocalTestCollection? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getAll(
|
||||
account: Account,
|
||||
provider: ContentProviderClient
|
||||
): List<LocalTestCollection> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
fromCollection: Collection
|
||||
) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTestCollection) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.di.SyncDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTestCollection,
|
||||
@Assisted collection: Collection,
|
||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
|
||||
account,
|
||||
httpClient,
|
||||
SyncDataType.EVENTS,
|
||||
syncResult,
|
||||
localCollection,
|
||||
collection,
|
||||
resync = null,
|
||||
syncDispatcher
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
collection: Collection
|
||||
): TestSyncManager
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
|
||||
return true
|
||||
}
|
||||
|
||||
var didQueryCapabilities = false
|
||||
override suspend fun queryCapabilities(): SyncState? {
|
||||
if (didQueryCapabilities)
|
||||
throw IllegalStateException("queryCapabilities() must not be called twice")
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
}
|
||||
|
||||
return cTag
|
||||
}
|
||||
|
||||
var didGenerateUpload = false
|
||||
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||
didGenerateUpload = true
|
||||
return resource.toString().toRequestBody()
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
|
||||
var didListAllRemote = false
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||
if (didListAllRemote)
|
||||
throw IllegalStateException("listAllRemote() must not be called twice")
|
||||
didListAllRemote = true
|
||||
for (result in listAllRemoteResult)
|
||||
callback.onResponse(result.first, result.second)
|
||||
}
|
||||
|
||||
var assertDownloadRemote = emptyMap<HttpUrl, String>()
|
||||
var didDownloadRemote = false
|
||||
override suspend fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
didDownloadRemote = true
|
||||
assertEquals(assertDownloadRemote.keys.toList(), bunch)
|
||||
|
||||
for ((url, eTag) in assertDownloadRemote) {
|
||||
val fileName = url.lastSegment
|
||||
var localEntry = localCollection.entries.firstOrNull { it.fileName == fileName }
|
||||
if (localEntry == null) {
|
||||
val newEntry = LocalTestResource().also {
|
||||
it.fileName = fileName
|
||||
}
|
||||
localCollection.entries += newEntry
|
||||
localEntry = newEntry
|
||||
}
|
||||
localEntry.eTag = eTag
|
||||
localEntry.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle() =
|
||||
throw NotImplementedError()
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountsCleanupWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var accountManager: AccountManager
|
||||
lateinit var addressBookAccountType: String
|
||||
lateinit var addressBookAccount: Account
|
||||
lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
accountManager = AccountManager.get(context)
|
||||
service = createTestService()
|
||||
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Remove the account here in any case; Nice to have when the test fails
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCleanUpServices_noAccount() {
|
||||
// Insert service that reference to invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that service is deleted
|
||||
assertNull(db.serviceDao().get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCleanUpServices_oneAccount() {
|
||||
TestAccount.provide { existingAccount ->
|
||||
// Insert services, one that reference the existing account and one that references an invalid account
|
||||
db.serviceDao().insertOrReplace(Service(id = 1, accountName = existingAccount.name, type = Service.TYPE_CALDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
|
||||
db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null))
|
||||
assertNotNull(db.serviceDao().get(2))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpServices()
|
||||
|
||||
// Verify that one service is deleted and the other one is kept
|
||||
assertNotNull(db.serviceDao().get(1))
|
||||
assertNull(db.serviceDao().get(2))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() {
|
||||
// Create address book account without corresponding account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
|
||||
// Verify account was deleted
|
||||
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCleanUpAddressBooks_keepsAddressBookWithAccount() {
|
||||
TestAccount.provide { existingAccount ->
|
||||
// Create address book account _with_ corresponding account and verify
|
||||
val userData = Bundle(2).apply {
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, existingAccount.name)
|
||||
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, existingAccount.type)
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
worker.cleanUpAddressBooks()
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createTestService(): Service {
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CARDDAV, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SystemAccountUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCreateAccount() {
|
||||
val userData = Bundle(2)
|
||||
userData.putString("int", "1")
|
||||
userData.putString("string", "abc/\"-")
|
||||
|
||||
val account = Account("AccountUtilsTest", context.getString(R.string.account_type))
|
||||
val manager = AccountManager.get(context)
|
||||
try {
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
|
||||
|
||||
// validate user data
|
||||
assertEquals("1", manager.getUserData(account, "int"))
|
||||
assertEquals("abc/\"-", manager.getUserData(account, "string"))
|
||||
} finally {
|
||||
assertTrue(manager.removeAccountExplicitly(account))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
object TestAccount {
|
||||
|
||||
private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
|
||||
/**
|
||||
* Creates a test account, usually in the `Before` setUp of a test.
|
||||
*
|
||||
* Remove it with [remove].
|
||||
*/
|
||||
fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account {
|
||||
val accountType = targetContext.getString(R.string.account_type)
|
||||
val account = Account(accountName, accountType)
|
||||
|
||||
val initialData = AccountSettings.initialUserData(null)
|
||||
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
|
||||
assertTrue(SystemAccountUtils.createAccount(targetContext, account, initialData))
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a test account, usually in the `@After` tearDown of a test.
|
||||
*/
|
||||
fun remove(account: Account) {
|
||||
val am = AccountManager.get(targetContext)
|
||||
assertTrue(am.removeAccountExplicitly(account))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to create a test account and remove it after executing the block.
|
||||
*/
|
||||
fun provide(version: Int = AccountSettings.CURRENT_VERSION, block: (Account) -> Unit) {
|
||||
val account = create(version)
|
||||
try {
|
||||
block(account)
|
||||
} finally {
|
||||
remove(account)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PeriodicSyncWorkerTest {
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context)
|
||||
|
||||
account = TestAccount.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() = runTest {
|
||||
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
|
||||
|
||||
// Run PeriodicSyncWorker as TestWorker
|
||||
val inputData = workDataOf(
|
||||
BaseSyncWorker.INPUT_DATA_TYPE to SyncDataType.EVENTS.toString(),
|
||||
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
|
||||
)
|
||||
|
||||
// observe WorkManager cancellation call
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
// run test worker, expect failure
|
||||
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
syncWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
val result = testWorker.doWork()
|
||||
assertTrue(result is ListenableWorker.Result.Failure)
|
||||
|
||||
// verify that worker called WorkManager.cancelWorkById(<its ID>)
|
||||
verify {
|
||||
workManager.cancelWorkById(testWorker.id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
import android.net.NetworkCapabilities.TRANSPORT_WIFI
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncConditionsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@MockK
|
||||
lateinit var capabilities: NetworkCapabilities
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var factory: SyncConditions.Factory
|
||||
|
||||
@MockK
|
||||
lateinit var network1: Network
|
||||
|
||||
@MockK
|
||||
lateinit var network2: Network
|
||||
|
||||
|
||||
private lateinit var accountSettings: AccountSettings
|
||||
|
||||
private lateinit var conditions: SyncConditions
|
||||
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
// prepare accountSettings with some necessary data
|
||||
accountSettings = mockk<AccountSettings> {
|
||||
every { account } returns Account("test", "test")
|
||||
every { getIgnoreVpns() } returns false // default value
|
||||
}
|
||||
|
||||
conditions = factory.create(accountSettings)
|
||||
|
||||
connectivityManager = context.getSystemService<ConnectivityManager>()!!.also { cm ->
|
||||
mockkObject(cm)
|
||||
every { cm.allNetworks } returns arrayOf(network1, network2)
|
||||
every { cm.getNetworkInfo(network1) } returns mockk()
|
||||
every { cm.getNetworkInfo(network2) } returns mockk()
|
||||
every { cm.getNetworkCapabilities(network1) } returns capabilities
|
||||
every { cm.getNetworkCapabilities(network2) } returns capabilities
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_CorrectWiFiSsid() {
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertTrue(conditions.correctWifiSsid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_WrongWiFiSsid() {
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertFalse(conditions.correctWifiSsid())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Internet() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Validated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_InternetValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
|
||||
assertFalse(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns_NotVpn() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
|
||||
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
|
||||
// 1. a mobile network without INTERNET, but with VALIDATED
|
||||
// 2. a WiFi network with INTERNET and VALIDATED
|
||||
|
||||
// The "return false" of hasINTERNET will trigger at the first connection, the
|
||||
// "andThen true" will trigger for the second connection
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
|
||||
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotVpn() {
|
||||
every { accountSettings.getIgnoreVpns() } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
|
||||
assertTrue(conditions.internetAvailable())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifi() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_validated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifiValidated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(conditions.wifiAvailable())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_withoutWifi() {
|
||||
// "Sync only over Wi-Fi" is disabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns false
|
||||
|
||||
assertTrue(factory.create(accountSettings).wifiConditionsMet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
|
||||
// "Sync only over Wi-Fi" is enabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns true
|
||||
|
||||
// Wi-Fi is available
|
||||
mockkObject(conditions) {
|
||||
// Wi-Fi is available
|
||||
every { conditions.wifiAvailable() } returns true
|
||||
|
||||
// Wi-Fi SSID is correct
|
||||
every { conditions.correctWifiSsid() } returns true
|
||||
|
||||
assertTrue(conditions.wifiConditionsMet())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
|
||||
// "Sync only over Wi-Fi" is enabled
|
||||
every { accountSettings.getSyncWifiOnly() } returns true
|
||||
|
||||
mockkObject(conditions) {
|
||||
// Wi-Fi is not available
|
||||
every { conditions.wifiAvailable() } returns false
|
||||
|
||||
// Wi-Fi SSID is correct
|
||||
every { conditions.correctWifiSsid() } returns true
|
||||
|
||||
assertFalse(conditions.wifiConditionsMet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncWorkerManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
// one-time sync workers
|
||||
|
||||
@Test
|
||||
fun testEnqueueOneTime() {
|
||||
val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
|
||||
val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS)
|
||||
assertEquals(workerName, returnedName)
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
|
||||
// periodic sync workers
|
||||
|
||||
@Test
|
||||
fun enablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
|
||||
syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltAndroidTest
|
||||
class CollectionSelectedUseCaseTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
val collection = Collection(
|
||||
id = 2,
|
||||
serviceId = 1,
|
||||
type = Collection.Companion.TYPE_CALENDAR,
|
||||
url = "https://example.com".toHttpUrl()
|
||||
)
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
val service = Service(
|
||||
id = 1,
|
||||
type = Service.Companion.TYPE_CALDAV,
|
||||
accountName = "test@example.com"
|
||||
)
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var pushRegistrationManager: PushRegistrationManager
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@BindValue
|
||||
@RelaxedMockK
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var useCase: CollectionSelectedUseCase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
serviceRepository.insertOrReplaceBlocking(service)
|
||||
collectionRepository.insertOrUpdateByUrl(collection)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
serviceRepository.deleteAllBlocking()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testHandleWithDelay() = runTest {
|
||||
useCase.handleWithDelay(collectionId = collection.id)
|
||||
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(any())
|
||||
pushRegistrationManager.update(service.id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DebugInfoActivityTest {
|
||||
|
||||
@Test
|
||||
fun testIntentBuilder_LargeLocalResource() {
|
||||
val a = 'A'.code.toByte()
|
||||
val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context)
|
||||
.withLocalResource(String(ByteArray(1024*1024) { a }))
|
||||
.build()
|
||||
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
|
||||
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
||||
expected.append("...")
|
||||
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIntentBuilder_LargeLogs() {
|
||||
val a = 'A'.code.toByte()
|
||||
val intent = DebugInfoActivity.IntentBuilder(InstrumentationRegistry.getInstrumentation().context)
|
||||
.withLogs(String(ByteArray(1024*1024) { a }))
|
||||
.build()
|
||||
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
|
||||
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
|
||||
expected.append("...")
|
||||
assertEquals(expected.toString(), intent.getStringExtra("logs"))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LoginActivityTest {
|
||||
|
||||
@Test
|
||||
fun loginInfoFromIntent() {
|
||||
val intent = Intent().apply {
|
||||
data = Uri.parse("https://example.com/nextcloud")
|
||||
putExtra(LoginActivity.EXTRA_USERNAME, "user")
|
||||
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
|
||||
}
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginInfoFromIntent_withPort() {
|
||||
val intent = Intent().apply {
|
||||
data = Uri.parse("https://example.com:444/nextcloud")
|
||||
putExtra(LoginActivity.EXTRA_USERNAME, "user")
|
||||
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
|
||||
}
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginInfoFromIntent_implicit() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com/path"))
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginInfoFromIntent_implicit_withPort() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com:0/path"))
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
|
||||
assertEquals("user", loginInfo.credentials!!.username)
|
||||
assertEquals("password", loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginInfoFromIntent_implicit_email() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:user@example.com"))
|
||||
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
|
||||
assertEquals(null, loginInfo.baseUri)
|
||||
assertEquals("user@example.com", loginInfo.credentials!!.username)
|
||||
assertEquals(null, loginInfo.credentials.password?.asString())
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CredentialsStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var store: CredentialsStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSetGetDelete() {
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString()))
|
||||
assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0))
|
||||
|
||||
store.setCredentials(0, null)
|
||||
assertNull(store.getCredentials(0))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class WebDavMountRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var repository: WebDavMountRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
val web = MockWebServer()
|
||||
val url = web.url("/")
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() = runTest {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertNull(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass1() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1"))
|
||||
assertEquals(url, repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass2() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 2"))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass3() = runTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 3"))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavMount
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class QueryChildDocumentsOperationTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
private lateinit var mount: WebDavMount
|
||||
private lateinit var rootDocument: WebDavDocument
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// create server and client
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = testDispatcher
|
||||
start()
|
||||
}
|
||||
|
||||
client = httpClientBuilder.build()
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// create WebDAV mount and root document in DB
|
||||
runBlocking {
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
mount = db.webDavMountDao().getById(mountId)
|
||||
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
|
||||
runBlocking {
|
||||
db.webDavMountDao().deleteAsync(mount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_insert() = runTest {
|
||||
// Query
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_update() = runTest {
|
||||
// Create parent and root in database
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(
|
||||
0,
|
||||
mount.id,
|
||||
rootDocument.id,
|
||||
"My_Books",
|
||||
true,
|
||||
"My Books",
|
||||
)
|
||||
)
|
||||
assertEquals("My_Books", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_delete() = runTest {
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
|
||||
)
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert folder got deleted
|
||||
assertEquals(null, db.webDavDocumentDao().get(folderId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
|
||||
// Create two directories
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
|
||||
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
|
||||
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
|
||||
assertEquals("parent1", parent1.name)
|
||||
assertEquals("parent2", parent2.name)
|
||||
|
||||
// Query - find children of two nodes simultaneously
|
||||
operation.queryChildren(parent1)
|
||||
operation.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
|
||||
assertEquals("childTwo.txt", db.webDavDocumentDao().getChildren(parent2Id)[0].name)
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher @Inject constructor(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
data class Resource(
|
||||
val name: String,
|
||||
val props: String
|
||||
)
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
logger.info("Request: $request")
|
||||
val requestPath = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val propsMap = mutableMapOf(
|
||||
PATH_WEBDAV_ROOT to arrayOf(
|
||||
Resource("",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
),
|
||||
Resource("Secret_Document.pages",
|
||||
"<displayname>Secret_Document.pages</displayname>",
|
||||
),
|
||||
Resource("MeowMeow_Cats.docx",
|
||||
"<displayname>MeowMeow_Cats.docx</displayname>"
|
||||
),
|
||||
Resource("Library",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Library</displayname>"
|
||||
)
|
||||
),
|
||||
|
||||
"$PATH_WEBDAV_ROOT/parent1" to arrayOf(
|
||||
Resource("childOne.txt",
|
||||
"<displayname>childOne.txt</displayname>"
|
||||
),
|
||||
),
|
||||
"$PATH_WEBDAV_ROOT/parent2" to arrayOf(
|
||||
Resource("childTwo.txt",
|
||||
"<displayname>childTwo.txt</displayname>"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val responses = propsMap[requestPath]?.joinToString { resource ->
|
||||
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
|
||||
resource.props +
|
||||
"</prop></propstat></response>"
|
||||
}
|
||||
|
||||
val multistatus =
|
||||
"<multistatus xmlns='DAV:' " +
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(multistatus)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue