merge: integrate Phase 1 (Site Metadata) with Phase 7 (Pages)

This commit is contained in:
Paweł Orzech 2026-03-20 00:34:46 +01:00
commit 0752238578
11 changed files with 2215 additions and 5 deletions

View file

@ -0,0 +1,42 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.swoosh.microblog.data.model.GhostSite
class SiteMetadataCache(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
fun save(accountId: String, site: GhostSite) {
val json = gson.toJson(site)
prefs.edit().putString(keyForAccount(accountId), json).apply()
}
fun get(accountId: String): GhostSite? {
val json = prefs.getString(keyForAccount(accountId), null) ?: return null
return try {
gson.fromJson(json, GhostSite::class.java)
} catch (e: Exception) {
null
}
}
fun getVersion(accountId: String): String? {
return get(accountId)?.version
}
fun remove(accountId: String) {
prefs.edit().remove(keyForAccount(accountId)).apply()
}
private fun keyForAccount(accountId: String): String = "site_$accountId"
companion object {
const val PREFS_NAME = "site_metadata_cache"
}
}

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.GhostSite
import com.swoosh.microblog.data.model.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper
@ -39,6 +40,9 @@ interface GhostApiService {
@Path("id") id: String
): Response<Unit>
@GET("ghost/api/admin/site/")
suspend fun getSite(): Response<GhostSite>
@GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -0,0 +1,12 @@
package com.swoosh.microblog.data.model
data class GhostSite(
val title: String?,
val description: String?,
val logo: String?,
val icon: String?,
val accent_color: String?,
val url: String?,
val version: String?,
val locale: String?
)

View file

@ -42,8 +42,12 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.animation.SwooshMotion
@ -209,6 +213,39 @@ fun ComposerScreen(
.fillMaxSize()
.padding(padding)
) {
// "Publishing to" chip when multiple accounts exist
val composerContext = LocalContext.current
val composerAccountManager = remember { AccountManager(composerContext) }
val composerAccounts = remember { composerAccountManager.getAccounts() }
val composerActiveAccount = remember { composerAccountManager.getActiveAccount() }
if (composerAccounts.size > 1 && composerActiveAccount != null) {
val composerSiteCache = remember { SiteMetadataCache(composerContext) }
val composerSiteData = remember {
composerSiteCache.get(composerActiveAccount.id)
}
val siteName = composerSiteData?.title ?: composerActiveAccount.name
val siteIconUrl = composerSiteData?.icon ?: composerSiteData?.logo
AssistChip(
onClick = { },
label = { Text("Publishing to: $siteName") },
leadingIcon = if (siteIconUrl != null) {
{
AsyncImage(
model = siteIconUrl,
contentDescription = null,
modifier = Modifier
.size(18.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
} else null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Hashtag visual transformation for edit mode text field
val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) {

View file

@ -81,6 +81,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.data.model.PostFilter
@ -113,6 +114,10 @@ fun FeedScreen(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val baseUrl = remember { CredentialsManager(context).ghostUrl }
val siteMetadataCache = remember { SiteMetadataCache(context) }
val siteData = remember(activeAccount?.id) {
activeAccount?.let { siteMetadataCache.get(it.id) }
}
// Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
@ -201,8 +206,19 @@ fun FeedScreen(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showAccountSwitcher = true }
) {
// Account color indicator
if (activeAccount != null) {
// Site icon or account avatar
val siteIconUrl = siteData?.icon ?: siteData?.logo
if (siteIconUrl != null) {
AsyncImage(
model = siteIconUrl,
contentDescription = "Site icon",
modifier = Modifier
.size(24.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
} else if (activeAccount != null) {
AccountAvatar(
account = activeAccount!!,
size = 28
@ -211,8 +227,12 @@ fun FeedScreen(
}
Column {
// Use blog name from site metadata, truncate to ~20 chars
val displayName = siteData?.title?.let {
if (it.length > 20) it.take(20) + "\u2026" else it
} ?: activeAccount?.name ?: "Swoosh"
Text(
text = activeAccount?.name ?: "Swoosh",
text = displayName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis

View file

@ -1,25 +1,38 @@
package com.swoosh.microblog.ui.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog
import com.swoosh.microblog.ui.feed.AccountAvatar
@ -37,6 +50,8 @@ fun SettingsScreen(
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val activeAccount = remember { accountManager.getActiveAccount() }
val siteMetadataCache = remember { SiteMetadataCache(context) }
val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } }
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
@ -77,6 +92,162 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
}
// --- Blog Info section ---
if (siteData != null) {
Text("Blog", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Site logo/icon or fallback initial
val siteImageUrl = siteData.logo ?: siteData.icon
if (siteImageUrl != null) {
AsyncImage(
model = siteImageUrl,
contentDescription = "Site icon",
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
val initial = siteData.title?.firstOrNull()?.uppercase() ?: "?"
val color = activeAccount?.let {
Color(GhostAccount.colorForIndex(it.colorIndex))
} ?: MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = siteData.title ?: "Ghost Blog",
style = MaterialTheme.typography.titleMedium
)
if (!siteData.description.isNullOrBlank()) {
Text(
text = siteData.description!!,
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// URL
val siteUrl = siteData.url
if (!siteUrl.isNullOrBlank()) {
Text(
text = siteUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Version + locale
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (siteData.version != null) {
Text(
text = "Ghost ${siteData.version}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (siteData.locale != null) {
Text(
text = "Locale: ${siteData.locale}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Version warning
val majorVersion = siteData.version?.split(".")?.firstOrNull()?.toIntOrNull()
if (majorVersion != null && majorVersion < 5) {
Spacer(modifier = Modifier.height(8.dp))
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Ghost ${siteData.version} is older than v5. Some features may not work.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Open Ghost Admin button
val ghostAdminUrl = remember(activeAccount) {
activeAccount?.blogUrl?.trimEnd('/') + "/ghost/"
}
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ghostAdminUrl))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Open Ghost Admin")
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
}
// --- Current Account section ---
Text("Current Account", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))

View file

@ -9,24 +9,30 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
@ -34,6 +40,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -102,6 +109,124 @@ fun SetupScreen(
)
// Content layered on top
if (state.showConfirmation) {
// Confirmation card
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + scaleIn(initialScale = 0.9f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Site icon
if (state.siteIcon != null) {
AsyncImage(
model = state.siteIcon,
contentDescription = "Site icon",
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
}
// Site title
Text(
text = state.siteName ?: "Ghost Blog",
style = MaterialTheme.typography.titleLarge
)
// Site description
if (!state.siteDescription.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.siteDescription!!,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Ghost version
if (state.siteVersion != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Ghost ${state.siteVersion}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Version warning
if (state.versionWarning) {
Spacer(modifier = Modifier.height(12.dp))
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Ghost version ${state.siteVersion} is older than v5. Some features may not work correctly.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Button(
onClick = viewModel::confirmConnection,
modifier = Modifier.fillMaxWidth()
) {
Text("Tak, po\u0142\u0105cz")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::cancelConfirmation,
modifier = Modifier.fillMaxWidth()
) {
Text("Wstecz")
}
}
}
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
@ -254,6 +379,7 @@ fun SetupScreen(
}
}
}
}
}
@Composable

View file

@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostJwtGenerator
import com.swoosh.microblog.data.repository.PostRepository
@ -16,6 +17,7 @@ import kotlinx.coroutines.launch
class SetupViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application)
private val siteMetadataCache = SiteMetadataCache(application)
private val _uiState = MutableStateFlow(SetupUiState(
isAddingAccount = accountManager.hasAnyAccount
@ -34,6 +36,30 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(accountName = name) }
}
fun confirmConnection() {
_uiState.update { it.copy(isSuccess = true) }
}
fun cancelConfirmation() {
// Remove the account that was added during save()
val accountId = _uiState.value.pendingAccountId
if (accountId != null) {
accountManager.removeAccount(accountId)
siteMetadataCache.remove(accountId)
}
_uiState.update {
it.copy(
showConfirmation = false,
siteName = null,
siteDescription = null,
siteIcon = null,
siteVersion = null,
versionWarning = false,
pendingAccountId = null
)
}
}
fun save() {
val state = _uiState.value
if (state.url.isBlank() || state.apiKey.isBlank()) {
@ -83,7 +109,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
}
} catch (_: Exception) { /* avatar is best-effort */ }
// Fetch site metadata
var siteLoaded = false
try {
val service = ApiClient.getService(
state.url,
apiKeyProvider = { state.apiKey }
)
val siteResponse = service.getSite()
val site = siteResponse.body()
if (site != null) {
siteMetadataCache.save(account.id, site)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
_uiState.update {
it.copy(
isTesting = false,
showConfirmation = true,
siteName = site.title,
siteDescription = site.description,
siteIcon = site.icon ?: site.logo,
siteVersion = site.version,
versionWarning = majorVersion != null && majorVersion < 5,
pendingAccountId = account.id
)
}
siteLoaded = true
}
} catch (_: Exception) { /* site metadata is best-effort */ }
// Fallback: skip confirmation if site fetch failed
if (!siteLoaded) {
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
}
},
onFailure = { e ->
// Remove the account since connection failed
@ -110,5 +168,12 @@ data class SetupUiState(
val isTesting: Boolean = false,
val isSuccess: Boolean = false,
val isAddingAccount: Boolean = false,
val error: String? = null
val error: String? = null,
val siteName: String? = null,
val siteDescription: String? = null,
val siteIcon: String? = null,
val siteVersion: String? = null,
val showConfirmation: Boolean = false,
val versionWarning: Boolean = false,
val pendingAccountId: String? = null
)

View file

@ -0,0 +1,161 @@
package com.swoosh.microblog.data
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.swoosh.microblog.data.model.GhostSite
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SiteMetadataCacheTest {
private lateinit var cache: SiteMetadataCache
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Application>()
cache = SiteMetadataCache(context)
}
@Test
fun `save and get round trip`() {
val site = GhostSite(
title = "My Blog",
description = "A test blog",
logo = "https://example.com/logo.png",
icon = "https://example.com/icon.png",
accent_color = "#ff1a75",
url = "https://example.com/",
version = "5.82.0",
locale = "en"
)
cache.save("account-1", site)
val retrieved = cache.get("account-1")
assertNotNull(retrieved)
assertEquals("My Blog", retrieved!!.title)
assertEquals("A test blog", retrieved.description)
assertEquals("https://example.com/logo.png", retrieved.logo)
assertEquals("https://example.com/icon.png", retrieved.icon)
assertEquals("#ff1a75", retrieved.accent_color)
assertEquals("https://example.com/", retrieved.url)
assertEquals("5.82.0", retrieved.version)
assertEquals("en", retrieved.locale)
}
@Test
fun `get returns null for unknown account`() {
val result = cache.get("nonexistent-account")
assertNull(result)
}
@Test
fun `getVersion returns version string`() {
val site = GhostSite(
title = "Blog",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
cache.save("account-2", site)
assertEquals("5.82.0", cache.getVersion("account-2"))
}
@Test
fun `getVersion returns null for unknown account`() {
assertNull(cache.getVersion("nonexistent"))
}
@Test
fun `save overwrites existing data`() {
val site1 = GhostSite(
title = "Old Title",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
val site2 = GhostSite(
title = "New Title",
description = "Updated",
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
cache.save("account-3", site1)
cache.save("account-3", site2)
val retrieved = cache.get("account-3")
assertEquals("New Title", retrieved?.title)
assertEquals("Updated", retrieved?.description)
assertEquals("5.82.0", retrieved?.version)
}
@Test
fun `different accounts have independent data`() {
val site1 = GhostSite(
title = "Blog One",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
val site2 = GhostSite(
title = "Blog Two",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "4.48.0",
locale = null
)
cache.save("account-a", site1)
cache.save("account-b", site2)
assertEquals("Blog One", cache.get("account-a")?.title)
assertEquals("Blog Two", cache.get("account-b")?.title)
}
@Test
fun `remove deletes cached data`() {
val site = GhostSite(
title = "To Remove",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.0.0",
locale = null
)
cache.save("account-remove", site)
assertNotNull(cache.get("account-remove"))
cache.remove("account-remove")
assertNull(cache.get("account-remove"))
}
}

View file

@ -0,0 +1,129 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class SiteModelsTest {
private val gson = Gson()
@Test
fun `deserialize full site response`() {
val json = """
{
"title": "My Ghost Blog",
"description": "A blog about things",
"logo": "https://example.com/logo.png",
"icon": "https://example.com/icon.png",
"accent_color": "#ff1a75",
"url": "https://example.com/",
"version": "5.82.0",
"locale": "en"
}
""".trimIndent()
val site = gson.fromJson(json, GhostSite::class.java)
assertEquals("My Ghost Blog", site.title)
assertEquals("A blog about things", site.description)
assertEquals("https://example.com/logo.png", site.logo)
assertEquals("https://example.com/icon.png", site.icon)
assertEquals("#ff1a75", site.accent_color)
assertEquals("https://example.com/", site.url)
assertEquals("5.82.0", site.version)
assertEquals("en", site.locale)
}
@Test
fun `deserialize site response with null fields`() {
val json = """
{
"title": "Minimal Blog",
"url": "https://minimal.ghost.io/"
}
""".trimIndent()
val site = gson.fromJson(json, GhostSite::class.java)
assertEquals("Minimal Blog", site.title)
assertNull(site.description)
assertNull(site.logo)
assertNull(site.icon)
assertNull(site.accent_color)
assertEquals("https://minimal.ghost.io/", site.url)
assertNull(site.version)
assertNull(site.locale)
}
@Test
fun `version parsing extracts major version`() {
val site = GhostSite(
title = "Test",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "5.82.0",
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertEquals(5, majorVersion)
}
@Test
fun `version parsing handles old version`() {
val site = GhostSite(
title = "Old Blog",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = "4.48.9",
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertEquals(4, majorVersion)
assertTrue((majorVersion ?: 0) < 5)
}
@Test
fun `version parsing handles null version`() {
val site = GhostSite(
title = "No Version",
description = null,
logo = null,
icon = null,
accent_color = null,
url = null,
version = null,
locale = null
)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
assertNull(majorVersion)
}
@Test
fun `serialize and deserialize round trip`() {
val original = GhostSite(
title = "Round Trip Blog",
description = "Testing serialization",
logo = "https://example.com/logo.png",
icon = "https://example.com/icon.png",
accent_color = "#15171a",
url = "https://example.com/",
version = "5.82.0",
locale = "pl"
)
val json = gson.toJson(original)
val deserialized = gson.fromJson(json, GhostSite::class.java)
assertEquals(original, deserialized)
}
}

File diff suppressed because it is too large Load diff