diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed89eeb..4203a51 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,12 @@ android { packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -102,6 +108,16 @@ dependencies { // Testing testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("com.google.code.gson:gson:2.10.1") + testImplementation("io.jsonwebtoken:jjwt-api:0.12.3") + testImplementation("io.jsonwebtoken:jjwt-impl:0.12.3") + testImplementation("io.jsonwebtoken:jjwt-gson:0.12.3") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.robolectric:robolectric:4.11.1") + testImplementation("androidx.test:core:1.5.0") + testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("androidx.arch.core:core-testing:2.2.0") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(composeBom) diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt new file mode 100644 index 0000000..0714851 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -0,0 +1,37 @@ +package com.swoosh.microblog.data + +import com.swoosh.microblog.data.model.LinkPreview + +/** + * Builds Ghost mobiledoc JSON from text content and optional link preview. + * Extracted as a shared utility used by both ComposerViewModel and PostUploadWorker. + */ +object MobiledocBuilder { + + fun build(text: String, linkPreview: LinkPreview?): String { + return build(text, linkPreview?.url, linkPreview?.title, linkPreview?.description) + } + + fun build( + text: String, + linkUrl: String?, + linkTitle: String?, + linkDescription: String? + ): String { + val escapedText = escapeForJson(text).replace("\n", "\\n") + + val cards = if (linkUrl != null) { + val escapedUrl = escapeForJson(linkUrl) + val escapedTitle = linkTitle?.let { escapeForJson(it) } ?: "" + val escapedDesc = linkDescription?.let { escapeForJson(it) } ?: "" + """["bookmark",{"url":"$escapedUrl","metadata":{"title":"$escapedTitle","description":"$escapedDesc"}}]""" + } else "" + + val cardSection = if (linkUrl != null) ",[10,0]" else "" + return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]$cardSection]}""" + } + + internal fun escapeForJson(value: String): String { + return value.replace("\\", "\\\\").replace("\"", "\\\"") + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt b/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt new file mode 100644 index 0000000..26cda3b --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/UrlNormalizer.kt @@ -0,0 +1,18 @@ +package com.swoosh.microblog.data + +/** + * Normalizes Ghost instance URLs to ensure proper format for Retrofit base URL. + */ +object UrlNormalizer { + + fun normalize(url: String): String { + var normalized = url.trim() + if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { + normalized = "https://$normalized" + } + if (!normalized.endsWith("/")) { + normalized = "$normalized/" + } + return normalized + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt b/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt index f9e8b9a..97d47fc 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/ApiClient.kt @@ -1,5 +1,6 @@ package com.swoosh.microblog.data.api +import com.swoosh.microblog.data.UrlNormalizer import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -15,7 +16,7 @@ object ApiClient { private var currentBaseUrl: String? = null fun getService(baseUrl: String, apiKeyProvider: () -> String?): GhostApiService { - val normalizedUrl = normalizeUrl(baseUrl) + val normalizedUrl = UrlNormalizer.normalize(baseUrl) if (retrofit == null || currentBaseUrl != normalizedUrl) { synchronized(this) { @@ -53,14 +54,4 @@ object ApiClient { } } - private fun normalizeUrl(url: String): String { - var normalized = url.trim() - if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { - normalized = "https://$normalized" - } - if (!normalized.endsWith("/")) { - normalized = "$normalized/" - } - return normalized - } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index fc1d533..86e9683 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.repository.OpenGraphFetcher import com.swoosh.microblog.data.repository.PostRepository @@ -126,7 +127,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application ) } - val mobiledoc = buildMobiledoc(state.text, state.linkPreview) + val mobiledoc = MobiledocBuilder.build(state.text, state.linkPreview) val ghostPost = GhostPost( title = title, @@ -173,19 +174,6 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application } } - private fun buildMobiledoc(text: String, linkPreview: LinkPreview?): String { - val escapedText = text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") - - val cards = if (linkPreview != null) { - val escapedUrl = linkPreview.url.replace("\\", "\\\\").replace("\"", "\\\"") - val escapedTitle = linkPreview.title?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: "" - val escapedDesc = linkPreview.description?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: "" - """,[\"bookmark\",{\"url\":\"$escapedUrl\",\"metadata\":{\"title\":\"$escapedTitle\",\"description\":\"$escapedDesc\"}}]""" - } else "" - - return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]${if (linkPreview != null) ",[10,0]" else ""}}""" - } - fun reset() { editingLocalId = null editingGhostId = null diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index 13aa581..bc6d465 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -132,7 +132,7 @@ fun DetailScreen( // Metadata Spacer(modifier = Modifier.height(24.dp)) - HorizontalDivider() + Divider() Spacer(modifier = Modifier.height(12.dp)) if (post.createdAt != null) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index c818a7e..b51c641 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -85,32 +85,30 @@ fun FeedScreen( } } - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> - PostCard( - post = post, - onClick = { onPostClick(post) }, - onCancelQueue = { viewModel.cancelQueuedPost(post) } - ) - } + if (state.isRefreshing) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } - if (state.isLoadingMore) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> + PostCard( + post = post, + onClick = { onPostClick(post) }, + onCancelQueue = { viewModel.cancelQueuedPost(post) } + ) + } + + if (state.isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index fb456c4..dac1611 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -91,7 +91,7 @@ fun SettingsScreen( } Spacer(modifier = Modifier.height(32.dp)) - HorizontalDivider() + Divider() Spacer(modifier = Modifier.height(16.dp)) OutlinedButton( diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index f235c49..17c9357 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -3,6 +3,7 @@ package com.swoosh.microblog.worker import android.content.Context import android.net.Uri import androidx.work.* +import com.swoosh.microblog.data.MobiledocBuilder import com.swoosh.microblog.data.model.GhostPost import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.repository.PostRepository @@ -28,17 +29,15 @@ class PostUploadWorker( var featureImage = post.uploadedImageUrl if (featureImage == null && post.imageUri != null) { val imageResult = repository.uploadImage(Uri.parse(post.imageUri)) - imageResult.fold( - onSuccess = { url -> featureImage = url }, - onFailure = { - repository.updateQueueStatus(post.localId, QueueStatus.FAILED) - allSuccess = false - continue - } - ) + if (imageResult.isFailure) { + repository.updateQueueStatus(post.localId, QueueStatus.FAILED) + allSuccess = false + continue + } + featureImage = imageResult.getOrNull() } - val mobiledoc = buildMobiledoc(post.content, post.linkUrl, post.linkTitle, post.linkDescription) + val mobiledoc = MobiledocBuilder.build(post.content, post.linkUrl, post.linkTitle, post.linkDescription) val ghostPost = GhostPost( title = post.title, @@ -75,24 +74,6 @@ class PostUploadWorker( return if (allSuccess) Result.success() else Result.retry() } - private fun buildMobiledoc( - text: String, - linkUrl: String?, - linkTitle: String?, - linkDescription: String? - ): String { - val escapedText = text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") - - val cards = if (linkUrl != null) { - val escapedUrl = linkUrl.replace("\\", "\\\\").replace("\"", "\\\"") - val escapedTitle = linkTitle?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: "" - val escapedDesc = linkDescription?.replace("\\", "\\\\")?.replace("\"", "\\\"") ?: "" - """,[\"bookmark\",{\"url\":\"$escapedUrl\",\"metadata\":{\"title\":\"$escapedTitle\",\"description\":\"$escapedDesc\"}}]""" - } else "" - - return """{"version":"0.3.1","atoms":[],"cards":[$cards],"markups":[],"sections":[[1,"p",[[0,[],0,"$escapedText"]]]${if (linkUrl != null) ",[10,0]" else ""}}""" - } - companion object { private const val WORK_NAME = "post_upload" private const val PERIODIC_WORK_NAME = "post_upload_periodic" diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index c85af78..02797f7 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -14,9 +14,8 @@ android:strokeColor="#FFFFFF" android:fillType="nonZero" /> + android:strokeColor="#FFFFFF" /> diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt new file mode 100644 index 0000000..4a36c39 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -0,0 +1,254 @@ +package com.swoosh.microblog.data + +import com.google.gson.JsonParser +import com.swoosh.microblog.data.model.LinkPreview +import org.junit.Assert.* +import org.junit.Test + +class MobiledocBuilderTest { + + // --- Plain text (no link) --- + + @Test + fun `build with plain text produces valid JSON`() { + val result = MobiledocBuilder.build("Hello world", null as LinkPreview?) + // Should parse as valid JSON + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with plain text contains correct version`() { + val result = MobiledocBuilder.build("Hello", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertEquals("0.3.1", json.get("version").asString) + } + + @Test + fun `build with plain text has empty atoms array`() { + val result = MobiledocBuilder.build("Hello", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertTrue(json.getAsJsonArray("atoms").isEmpty) + } + + @Test + fun `build with plain text has empty cards when no link`() { + val result = MobiledocBuilder.build("Hello", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertTrue(json.getAsJsonArray("cards").isEmpty) + } + + @Test + fun `build with plain text has empty markups array`() { + val result = MobiledocBuilder.build("Hello", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertTrue(json.getAsJsonArray("markups").isEmpty) + } + + @Test + fun `build with plain text includes text in sections`() { + val result = MobiledocBuilder.build("Hello world", null as LinkPreview?) + assertTrue("Should contain the text", result.contains("Hello world")) + } + + @Test + fun `build with plain text has one section when no link`() { + val result = MobiledocBuilder.build("Hello", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertEquals(1, json.getAsJsonArray("sections").size()) + } + + // --- Text escaping --- + + @Test + fun `build escapes backslashes in text`() { + val result = MobiledocBuilder.build("path\\to\\file", null as LinkPreview?) + assertTrue(result.contains("path\\\\to\\\\file")) + } + + @Test + fun `build escapes double quotes in text`() { + val result = MobiledocBuilder.build("say \"hello\"", null as LinkPreview?) + assertTrue(result.contains("say \\\"hello\\\"")) + } + + @Test + fun `build escapes newlines in text`() { + val result = MobiledocBuilder.build("line1\nline2", null as LinkPreview?) + assertTrue(result.contains("line1\\nline2")) + } + + @Test + fun `build handles empty text`() { + val result = MobiledocBuilder.build("", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build handles text with special chars`() { + val result = MobiledocBuilder.build("café & résumé ", null as LinkPreview?) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull("Should produce valid JSON even with special chars", json) + } + + // --- With LinkPreview --- + + @Test + fun `build with link preview produces valid JSON`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Example", + description = "A description", + imageUrl = null + ) + val result = MobiledocBuilder.build("Check this out", preview) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with link preview includes bookmark card`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Example", + description = "Desc", + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + assertTrue("Should contain bookmark", result.contains("bookmark")) + assertTrue("Should contain URL", result.contains("https://example.com")) + } + + @Test + fun `build with link preview has two sections`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Title", + description = null, + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + val json = JsonParser.parseString(result).asJsonObject + assertEquals("Should have text section and card section", 2, json.getAsJsonArray("sections").size()) + } + + @Test + fun `build with link preview has one card`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Title", + description = "Desc", + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + val json = JsonParser.parseString(result).asJsonObject + assertEquals(1, json.getAsJsonArray("cards").size()) + } + + @Test + fun `build with link preview escapes URL`() { + val preview = LinkPreview( + url = "https://example.com/path?q=\"test\"", + title = "Title", + description = null, + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + // Should not break JSON parsing + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + } + + @Test + fun `build with null link title uses empty string`() { + val preview = LinkPreview( + url = "https://example.com", + title = null, + description = null, + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + // Title in metadata should be empty + val card = json.getAsJsonArray("cards").get(0).asJsonArray + val cardData = card.get(1).asJsonObject + val metadata = cardData.getAsJsonObject("metadata") + assertEquals("", metadata.get("title").asString) + } + + @Test + fun `build with null link description uses empty string`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Title", + description = null, + imageUrl = null + ) + val result = MobiledocBuilder.build("Text", preview) + val json = JsonParser.parseString(result).asJsonObject + val card = json.getAsJsonArray("cards").get(0).asJsonArray + val cardData = card.get(1).asJsonObject + val metadata = cardData.getAsJsonObject("metadata") + assertEquals("", metadata.get("description").asString) + } + + // --- Overloaded method with separate params --- + + @Test + fun `build with separate params and no link produces same as null preview`() { + val resultA = MobiledocBuilder.build("Hello", null as LinkPreview?) + val resultB = MobiledocBuilder.build("Hello", null, null, null) + assertEquals(resultA, resultB) + } + + @Test + fun `build with separate params includes link data`() { + val result = MobiledocBuilder.build( + "Text", + "https://test.com", + "Test Title", + "Test Desc" + ) + assertTrue(result.contains("https://test.com")) + assertTrue(result.contains("Test Title")) + assertTrue(result.contains("Test Desc")) + assertTrue(result.contains("bookmark")) + } + + @Test + fun `build with separate params handles null title and description`() { + val result = MobiledocBuilder.build("Text", "https://test.com", null, null) + val json = JsonParser.parseString(result).asJsonObject + assertNotNull(json) + assertTrue(result.contains("bookmark")) + } + + // --- escapeForJson --- + + @Test + fun `escapeForJson escapes backslash`() { + assertEquals("\\\\", MobiledocBuilder.escapeForJson("\\")) + } + + @Test + fun `escapeForJson escapes double quote`() { + assertEquals("\\\"", MobiledocBuilder.escapeForJson("\"")) + } + + @Test + fun `escapeForJson handles mixed special chars`() { + assertEquals("a\\\\b\\\"c", MobiledocBuilder.escapeForJson("a\\b\"c")) + } + + @Test + fun `escapeForJson leaves normal text unchanged`() { + assertEquals("hello world", MobiledocBuilder.escapeForJson("hello world")) + } + + @Test + fun `escapeForJson handles empty string`() { + assertEquals("", MobiledocBuilder.escapeForJson("")) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/UrlNormalizerTest.kt b/app/src/test/java/com/swoosh/microblog/data/UrlNormalizerTest.kt new file mode 100644 index 0000000..cafcd96 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/UrlNormalizerTest.kt @@ -0,0 +1,83 @@ +package com.swoosh.microblog.data + +import org.junit.Assert.* +import org.junit.Test + +class UrlNormalizerTest { + + @Test + fun `adds https prefix when no protocol`() { + assertEquals("https://example.com/", UrlNormalizer.normalize("example.com")) + } + + @Test + fun `preserves http prefix`() { + assertEquals("http://example.com/", UrlNormalizer.normalize("http://example.com")) + } + + @Test + fun `preserves https prefix`() { + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com")) + } + + @Test + fun `adds trailing slash when missing`() { + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com")) + } + + @Test + fun `preserves trailing slash when present`() { + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com/")) + } + + @Test + fun `trims leading whitespace`() { + assertEquals("https://example.com/", UrlNormalizer.normalize(" example.com")) + } + + @Test + fun `trims trailing whitespace`() { + assertEquals("https://example.com/", UrlNormalizer.normalize("example.com ")) + } + + @Test + fun `trims both leading and trailing whitespace`() { + assertEquals("https://example.com/", UrlNormalizer.normalize(" example.com ")) + } + + @Test + fun `handles url with path`() { + assertEquals("https://blog.example.com/ghost/", UrlNormalizer.normalize("blog.example.com/ghost")) + } + + @Test + fun `handles url with path and trailing slash`() { + assertEquals("https://blog.example.com/ghost/", UrlNormalizer.normalize("blog.example.com/ghost/")) + } + + @Test + fun `handles subdomain urls`() { + assertEquals("https://my-blog.ghost.io/", UrlNormalizer.normalize("my-blog.ghost.io")) + } + + @Test + fun `handles http url with trailing slash`() { + assertEquals("http://localhost:2368/", UrlNormalizer.normalize("http://localhost:2368/")) + } + + @Test + fun `handles url with port`() { + assertEquals("https://localhost:2368/", UrlNormalizer.normalize("localhost:2368")) + } + + @Test + fun `preserves http with port`() { + assertEquals("http://localhost:2368/", UrlNormalizer.normalize("http://localhost:2368")) + } + + @Test + fun `handles HTTPS in mixed case`() { + // Should not add another https prefix + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com")) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/api/GhostAuthInterceptorTest.kt b/app/src/test/java/com/swoosh/microblog/data/api/GhostAuthInterceptorTest.kt new file mode 100644 index 0000000..d06cf48 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/api/GhostAuthInterceptorTest.kt @@ -0,0 +1,113 @@ +package com.swoosh.microblog.data.api + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class GhostAuthInterceptorTest { + + private lateinit var server: MockWebServer + + @Before + fun setup() { + server = MockWebServer() + server.start() + } + + @After + fun teardown() { + server.shutdown() + } + + @Test + fun `interceptor adds Authorization header when API key is provided`() { + val apiKey = "abcdef1234567890:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + val interceptor = GhostAuthInterceptor { apiKey } + + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall(Request.Builder().url(server.url("/test")).build()).execute() + + val recordedRequest = server.takeRequest() + val authHeader = recordedRequest.getHeader("Authorization") + assertNotNull("Authorization header should be present", authHeader) + assertTrue("Should start with 'Ghost '", authHeader!!.startsWith("Ghost ")) + // The rest should be a valid JWT (3 dot-separated parts) + val token = authHeader.removePrefix("Ghost ") + assertEquals("JWT should have 3 parts", 3, token.split(".").size) + } + + @Test + fun `interceptor does not add Authorization header when API key is null`() { + val interceptor = GhostAuthInterceptor { null } + + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall(Request.Builder().url(server.url("/test")).build()).execute() + + val recordedRequest = server.takeRequest() + val authHeader = recordedRequest.getHeader("Authorization") + assertNull("Authorization header should not be present", authHeader) + } + + @Test + fun `interceptor preserves original request headers`() { + val apiKey = "abcdef1234567890:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + val interceptor = GhostAuthInterceptor { apiKey } + + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + server.enqueue(MockResponse().setResponseCode(200)) + + val request = Request.Builder() + .url(server.url("/test")) + .addHeader("X-Custom", "value123") + .build() + + client.newCall(request).execute() + + val recordedRequest = server.takeRequest() + assertEquals("value123", recordedRequest.getHeader("X-Custom")) + assertNotNull(recordedRequest.getHeader("Authorization")) + } + + @Test + fun `interceptor uses fresh token on each request`() { + val apiKey = "abcdef1234567890:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + val interceptor = GhostAuthInterceptor { apiKey } + + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + server.enqueue(MockResponse().setResponseCode(200)) + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall(Request.Builder().url(server.url("/1")).build()).execute() + val token1 = server.takeRequest().getHeader("Authorization") + + // Small delay to ensure different iat + Thread.sleep(1100) + + client.newCall(Request.Builder().url(server.url("/2")).build()).execute() + val token2 = server.takeRequest().getHeader("Authorization") + + // Tokens should differ because iat changes each second + assertNotEquals("Tokens should be fresh each time", token1, token2) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/api/GhostJwtGeneratorTest.kt b/app/src/test/java/com/swoosh/microblog/data/api/GhostJwtGeneratorTest.kt new file mode 100644 index 0000000..f2f05f5 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/api/GhostJwtGeneratorTest.kt @@ -0,0 +1,111 @@ +package com.swoosh.microblog.data.api + +import io.jsonwebtoken.Jwts +import org.junit.Assert.* +import org.junit.Test +import java.util.Base64 +import javax.crypto.spec.SecretKeySpec + +class GhostJwtGeneratorTest { + + // A valid test key: 16-char hex ID : 64-char hex secret + private val validKeyId = "abcdef1234567890" + private val validSecret = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + private val validApiKey = "$validKeyId:$validSecret" + + @Test + fun `generateToken produces valid JWT with correct header`() { + val token = GhostJwtGenerator.generateToken(validApiKey) + + // JWT has three parts separated by dots + val parts = token.split(".") + assertEquals("JWT should have 3 parts", 3, parts.size) + + // Decode header and verify kid + val headerJson = String(Base64.getUrlDecoder().decode(parts[0])) + assertTrue("Header should contain kid", headerJson.contains("\"kid\"")) + assertTrue("Header should contain correct key ID", headerJson.contains(validKeyId)) + assertTrue("Header should specify HS256", headerJson.contains("HS256")) + } + + @Test + fun `generateToken creates JWT with correct claims`() { + val beforeGeneration = System.currentTimeMillis() / 1000 + val token = GhostJwtGenerator.generateToken(validApiKey) + val afterGeneration = System.currentTimeMillis() / 1000 + + // Verify by parsing the token with the same key + val secretBytes = validSecret.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val key = SecretKeySpec(secretBytes, "HmacSHA256") + + val claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + + // Check audience + val audience = claims.payload.audience + assertTrue("Audience should contain /admin/", audience.contains("/admin/")) + + // Check issued at + val iat = claims.payload.issuedAt.time / 1000 + assertTrue("iat should be around now", iat >= beforeGeneration - 1 && iat <= afterGeneration + 1) + + // Check expiration (5 minutes from iat) + val exp = claims.payload.expiration.time / 1000 + val expectedExp = iat + 300 + assertTrue("exp should be ~5 minutes after iat", exp >= expectedExp - 1 && exp <= expectedExp + 1) + } + + @Test + fun `generateToken with different keys produces different tokens`() { + val key1 = "aaaa1234567890bb:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + val key2 = "cccc1234567890dd:b1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + + val token1 = GhostJwtGenerator.generateToken(key1) + val token2 = GhostJwtGenerator.generateToken(key2) + + assertNotEquals("Different keys should produce different tokens", token1, token2) + } + + @Test(expected = IllegalArgumentException::class) + fun `generateToken throws on missing colon`() { + GhostJwtGenerator.generateToken("invalidkeyformat") + } + + @Test(expected = IllegalArgumentException::class) + fun `generateToken throws on empty key`() { + GhostJwtGenerator.generateToken("") + } + + @Test(expected = IllegalArgumentException::class) + fun `generateToken throws on multiple colons`() { + GhostJwtGenerator.generateToken("a:b:c") + } + + @Test + fun `generateToken correctly decodes hex secret`() { + // Use a known hex value to verify decoding + val knownHex = "48656c6c6f" // "Hello" in hex + val key = "testid:${knownHex}00000000000000000000000000000000000000000000000000000000" + // Should not throw - proves hex decoding works + val token = GhostJwtGenerator.generateToken(key) + assertNotNull(token) + assertTrue(token.isNotEmpty()) + } + + @Test + fun `generateToken header contains kid matching key id`() { + val keyId = "my_custom_key_id" + val secret = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + val token = GhostJwtGenerator.generateToken("$keyId:$secret") + + val headerJson = String(Base64.getUrlDecoder().decode(token.split(".")[0])) + assertTrue("Header should contain the exact key ID", headerJson.contains(keyId)) + } + + @Test(expected = NumberFormatException::class) + fun `generateToken throws on invalid hex in secret`() { + GhostJwtGenerator.generateToken("keyid:ZZZZ") + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt new file mode 100644 index 0000000..4c53962 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/db/ConvertersTest.kt @@ -0,0 +1,119 @@ +package com.swoosh.microblog.data.db + +import com.swoosh.microblog.data.model.PostStatus +import com.swoosh.microblog.data.model.QueueStatus +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class ConvertersTest { + + private lateinit var converters: Converters + + @Before + fun setup() { + converters = Converters() + } + + // --- PostStatus conversions --- + + @Test + fun `fromPostStatus DRAFT returns DRAFT string`() { + assertEquals("DRAFT", converters.fromPostStatus(PostStatus.DRAFT)) + } + + @Test + fun `fromPostStatus PUBLISHED returns PUBLISHED string`() { + assertEquals("PUBLISHED", converters.fromPostStatus(PostStatus.PUBLISHED)) + } + + @Test + fun `fromPostStatus SCHEDULED returns SCHEDULED string`() { + assertEquals("SCHEDULED", converters.fromPostStatus(PostStatus.SCHEDULED)) + } + + @Test + fun `toPostStatus DRAFT returns PostStatus DRAFT`() { + assertEquals(PostStatus.DRAFT, converters.toPostStatus("DRAFT")) + } + + @Test + fun `toPostStatus PUBLISHED returns PostStatus PUBLISHED`() { + assertEquals(PostStatus.PUBLISHED, converters.toPostStatus("PUBLISHED")) + } + + @Test + fun `toPostStatus SCHEDULED returns PostStatus SCHEDULED`() { + assertEquals(PostStatus.SCHEDULED, converters.toPostStatus("SCHEDULED")) + } + + @Test + fun `PostStatus round-trip conversion preserves value`() { + for (status in PostStatus.values()) { + val str = converters.fromPostStatus(status) + val restored = converters.toPostStatus(str) + assertEquals("Round-trip failed for $status", status, restored) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `toPostStatus throws on invalid string`() { + converters.toPostStatus("INVALID") + } + + // --- QueueStatus conversions --- + + @Test + fun `fromQueueStatus NONE returns NONE string`() { + assertEquals("NONE", converters.fromQueueStatus(QueueStatus.NONE)) + } + + @Test + fun `fromQueueStatus QUEUED_PUBLISH returns correct string`() { + assertEquals("QUEUED_PUBLISH", converters.fromQueueStatus(QueueStatus.QUEUED_PUBLISH)) + } + + @Test + fun `fromQueueStatus QUEUED_SCHEDULED returns correct string`() { + assertEquals("QUEUED_SCHEDULED", converters.fromQueueStatus(QueueStatus.QUEUED_SCHEDULED)) + } + + @Test + fun `fromQueueStatus UPLOADING returns correct string`() { + assertEquals("UPLOADING", converters.fromQueueStatus(QueueStatus.UPLOADING)) + } + + @Test + fun `fromQueueStatus FAILED returns correct string`() { + assertEquals("FAILED", converters.fromQueueStatus(QueueStatus.FAILED)) + } + + @Test + fun `toQueueStatus NONE returns QueueStatus NONE`() { + assertEquals(QueueStatus.NONE, converters.toQueueStatus("NONE")) + } + + @Test + fun `toQueueStatus QUEUED_PUBLISH returns correct enum`() { + assertEquals(QueueStatus.QUEUED_PUBLISH, converters.toQueueStatus("QUEUED_PUBLISH")) + } + + @Test + fun `toQueueStatus FAILED returns correct enum`() { + assertEquals(QueueStatus.FAILED, converters.toQueueStatus("FAILED")) + } + + @Test + fun `QueueStatus round-trip conversion preserves value`() { + for (status in QueueStatus.values()) { + val str = converters.fromQueueStatus(status) + val restored = converters.toQueueStatus(str) + assertEquals("Round-trip failed for $status", status, restored) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `toQueueStatus throws on invalid string`() { + converters.toQueueStatus("NONEXISTENT") + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt new file mode 100644 index 0000000..6f4f370 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt @@ -0,0 +1,255 @@ +package com.swoosh.microblog.data.model + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class GhostModelsTest { + + private val gson = Gson() + + // --- LocalPost defaults --- + + @Test + fun `LocalPost default status is DRAFT`() { + val post = LocalPost() + assertEquals(PostStatus.DRAFT, post.status) + } + + @Test + fun `LocalPost default queueStatus is NONE`() { + val post = LocalPost() + assertEquals(QueueStatus.NONE, post.queueStatus) + } + + @Test + fun `LocalPost default title is empty`() { + val post = LocalPost() + assertEquals("", post.title) + } + + @Test + fun `LocalPost default content is empty`() { + val post = LocalPost() + assertEquals("", post.content) + } + + @Test + fun `LocalPost default ghostId is null`() { + val post = LocalPost() + assertNull(post.ghostId) + } + + @Test + fun `LocalPost default imageUri is null`() { + val post = LocalPost() + assertNull(post.imageUri) + } + + @Test + fun `LocalPost createdAt is set on construction`() { + val before = System.currentTimeMillis() + val post = LocalPost() + val after = System.currentTimeMillis() + assertTrue(post.createdAt in before..after) + } + + // --- GhostPost defaults --- + + @Test + fun `GhostPost default visibility is public`() { + val post = GhostPost() + assertEquals("public", post.visibility) + } + + @Test + fun `GhostPost all fields default to null except visibility`() { + val post = GhostPost() + assertNull(post.id) + assertNull(post.title) + assertNull(post.html) + assertNull(post.plaintext) + assertNull(post.mobiledoc) + assertNull(post.status) + assertNull(post.feature_image) + assertNull(post.created_at) + assertNull(post.updated_at) + assertNull(post.published_at) + assertNull(post.custom_excerpt) + assertNull(post.authors) + } + + // --- FeedPost --- + + @Test + fun `FeedPost default isLocal is false`() { + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertFalse(post.isLocal) + } + + @Test + fun `FeedPost default queueStatus is NONE`() { + val post = FeedPost( + title = "Test", + textContent = "Content", + htmlContent = null, + imageUrl = null, + linkUrl = null, + linkTitle = null, + linkDescription = null, + linkImageUrl = null, + status = "published", + publishedAt = null, + createdAt = null, + updatedAt = null + ) + assertEquals(QueueStatus.NONE, post.queueStatus) + } + + // --- GSON serialization --- + + @Test + fun `GhostPost serializes to JSON correctly`() { + val post = GhostPost( + id = "abc123", + title = "Test Post", + status = "published", + visibility = "public" + ) + val json = gson.toJson(post) + assertTrue(json.contains("\"id\":\"abc123\"")) + assertTrue(json.contains("\"title\":\"Test Post\"")) + assertTrue(json.contains("\"status\":\"published\"")) + } + + @Test + fun `GhostPost deserializes from JSON correctly`() { + val json = """{"id":"xyz","title":"Hello","status":"draft","visibility":"public"}""" + val post = gson.fromJson(json, GhostPost::class.java) + assertEquals("xyz", post.id) + assertEquals("Hello", post.title) + assertEquals("draft", post.status) + assertEquals("public", post.visibility) + } + + @Test + fun `GhostPost deserializes with missing optional fields`() { + val json = """{"id":"test"}""" + val post = gson.fromJson(json, GhostPost::class.java) + assertEquals("test", post.id) + assertNull(post.title) + assertNull(post.html) + assertNull(post.status) + } + + @Test + fun `PostsResponse deserializes with posts and pagination`() { + val json = """{ + "posts": [{"id": "1", "title": "First"}], + "meta": {"pagination": {"page": 1, "limit": 15, "pages": 3, "total": 42, "next": 2, "prev": null}} + }""" + val response = gson.fromJson(json, PostsResponse::class.java) + assertEquals(1, response.posts.size) + assertEquals("1", response.posts[0].id) + assertEquals(1, response.meta?.pagination?.page) + assertEquals(3, response.meta?.pagination?.pages) + assertEquals(42, response.meta?.pagination?.total) + assertEquals(2, response.meta?.pagination?.next) + assertNull(response.meta?.pagination?.prev) + } + + @Test + fun `PostWrapper wraps posts for API request`() { + val wrapper = PostWrapper(listOf(GhostPost(title = "New Post", status = "draft"))) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"posts\"")) + assertTrue(json.contains("\"New Post\"")) + } + + @Test + fun `Author deserializes correctly`() { + val json = """{"id":"author1","name":"John"}""" + val author = gson.fromJson(json, Author::class.java) + assertEquals("author1", author.id) + assertEquals("John", author.name) + } + + @Test + fun `Pagination with no next page`() { + val json = """{"page":3,"limit":15,"pages":3,"total":45,"next":null,"prev":2}""" + val pagination = gson.fromJson(json, Pagination::class.java) + assertNull(pagination.next) + assertEquals(2, pagination.prev) + assertEquals(3, pagination.page) + } + + // --- Enum values --- + + @Test + fun `PostStatus has exactly 3 values`() { + assertEquals(3, PostStatus.values().size) + } + + @Test + fun `QueueStatus has exactly 5 values`() { + assertEquals(5, QueueStatus.values().size) + } + + @Test + fun `PostStatus valueOf works for all values`() { + assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT")) + assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED")) + assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED")) + } + + @Test + fun `QueueStatus valueOf works for all values`() { + assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE")) + assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH")) + assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED")) + assertEquals(QueueStatus.UPLOADING, QueueStatus.valueOf("UPLOADING")) + assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED")) + } + + // --- LinkPreview --- + + @Test + fun `LinkPreview stores all fields correctly`() { + val preview = LinkPreview( + url = "https://example.com", + title = "Example Title", + description = "A description", + imageUrl = "https://example.com/img.png" + ) + assertEquals("https://example.com", preview.url) + assertEquals("Example Title", preview.title) + assertEquals("A description", preview.description) + assertEquals("https://example.com/img.png", preview.imageUrl) + } + + @Test + fun `LinkPreview allows null optional fields`() { + val preview = LinkPreview( + url = "https://example.com", + title = null, + description = null, + imageUrl = null + ) + assertNull(preview.title) + assertNull(preview.description) + assertNull(preview.imageUrl) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/ui/feed/FormatRelativeTimeTest.kt b/app/src/test/java/com/swoosh/microblog/ui/feed/FormatRelativeTimeTest.kt new file mode 100644 index 0000000..d5dc6ca --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/ui/feed/FormatRelativeTimeTest.kt @@ -0,0 +1,157 @@ +package com.swoosh.microblog.ui.feed + +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +class FormatRelativeTimeTest { + + private fun instantMinusMinutes(minutes: Long): String { + return Instant.now().minus(minutes, ChronoUnit.MINUTES) + .atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } + + private fun instantMinusHours(hours: Long): String { + return Instant.now().minus(hours, ChronoUnit.HOURS) + .atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } + + private fun instantMinusDays(days: Long): String { + return Instant.now().minus(days, ChronoUnit.DAYS) + .atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } + + @Test + fun `null input returns empty string`() { + assertEquals("", formatRelativeTime(null)) + } + + @Test + fun `empty string returns empty string`() { + assertEquals("", formatRelativeTime("")) + } + + @Test + fun `invalid date string returns empty string`() { + assertEquals("", formatRelativeTime("not-a-date")) + } + + @Test + fun `just now returns now`() { + val now = Instant.now().atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + assertEquals("now", formatRelativeTime(now)) + } + + @Test + fun `30 seconds ago returns now`() { + val thirtySecsAgo = Instant.now().minusSeconds(30) + .atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + assertEquals("now", formatRelativeTime(thirtySecsAgo)) + } + + @Test + fun `1 minute ago returns 1m ago`() { + val result = formatRelativeTime(instantMinusMinutes(1)) + assertEquals("1m ago", result) + } + + @Test + fun `5 minutes ago returns 5m ago`() { + val result = formatRelativeTime(instantMinusMinutes(5)) + assertEquals("5m ago", result) + } + + @Test + fun `59 minutes ago returns 59m ago`() { + val result = formatRelativeTime(instantMinusMinutes(59)) + assertEquals("59m ago", result) + } + + @Test + fun `1 hour ago returns 1h ago`() { + val result = formatRelativeTime(instantMinusHours(1)) + assertEquals("1h ago", result) + } + + @Test + fun `12 hours ago returns 12h ago`() { + val result = formatRelativeTime(instantMinusHours(12)) + assertEquals("12h ago", result) + } + + @Test + fun `23 hours ago returns 23h ago`() { + val result = formatRelativeTime(instantMinusHours(23)) + assertEquals("23h ago", result) + } + + @Test + fun `1 day ago returns 1d ago`() { + val result = formatRelativeTime(instantMinusDays(1)) + assertEquals("1d ago", result) + } + + @Test + fun `3 days ago returns 3d ago`() { + val result = formatRelativeTime(instantMinusDays(3)) + assertEquals("3d ago", result) + } + + @Test + fun `6 days ago returns 6d ago`() { + val result = formatRelativeTime(instantMinusDays(6)) + assertEquals("6d ago", result) + } + + @Test + fun `7 days ago returns formatted date`() { + val sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS) + val expected = DateTimeFormatter.ofPattern("MMM d") + .format(ZonedDateTime.ofInstant(sevenDaysAgo, ZoneId.systemDefault())) + val result = formatRelativeTime(instantMinusDays(7)) + assertEquals(expected, result) + } + + @Test + fun `30 days ago returns formatted date`() { + val thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS) + val expected = DateTimeFormatter.ofPattern("MMM d") + .format(ZonedDateTime.ofInstant(thirtyDaysAgo, ZoneId.systemDefault())) + val result = formatRelativeTime(instantMinusDays(30)) + assertEquals(expected, result) + } + + @Test + fun `parses ISO instant format`() { + val iso = Instant.now().minus(5, ChronoUnit.MINUTES).toString() + val result = formatRelativeTime(iso) + assertEquals("5m ago", result) + } + + @Test + fun `parses zoned datetime with timezone offset`() { + val zoned = Instant.now().minus(2, ChronoUnit.HOURS) + .atZone(ZoneId.of("America/New_York")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + val result = formatRelativeTime(zoned) + assertEquals("2h ago", result) + } + + @Test + fun `parses zoned datetime with Z suffix`() { + val utc = Instant.now().minus(3, ChronoUnit.HOURS) + .atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + val result = formatRelativeTime(utc) + assertEquals("3h ago", result) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 413d3d3..c7800b1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,7 +6,8 @@ pluginManagement { } } -dependencyResolution { +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral()