From d0019947f80a8b592f54d52c2e4fef7241429494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:25:34 +0100 Subject: [PATCH] feat: add extended tag model (GhostTagFull) and tag CRUD API endpoints Add TagModels.kt with GhostTagFull, TagsResponse, TagWrapper, TagCount data classes for full Ghost tag management. Add getTags, getTag, createTag, updateTag, deleteTag endpoints to GhostApiService. --- .../microblog/data/api/GhostApiService.kt | 22 ++ .../swoosh/microblog/data/model/TagModels.kt | 26 +++ .../microblog/data/model/TagModelsTest.kt | 196 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index cd41155..199b886 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -2,6 +2,8 @@ package com.swoosh.microblog.data.api import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostsResponse +import com.swoosh.microblog.data.model.TagsResponse +import com.swoosh.microblog.data.model.TagWrapper import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.Response @@ -40,6 +42,26 @@ interface GhostApiService { @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response + @GET("ghost/api/admin/tags/") + suspend fun getTags( + @Query("limit") limit: String = "all", + @Query("include") include: String = "count.posts" + ): Response + + @GET("ghost/api/admin/tags/{id}/") + suspend fun getTag(@Path("id") id: String): Response + + @POST("ghost/api/admin/tags/") + @Headers("Content-Type: application/json") + suspend fun createTag(@Body body: TagWrapper): Response + + @PUT("ghost/api/admin/tags/{id}/") + @Headers("Content-Type: application/json") + suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response + + @DELETE("ghost/api/admin/tags/{id}/") + suspend fun deleteTag(@Path("id") id: String): Response + @Multipart @POST("ghost/api/admin/images/upload/") suspend fun uploadImage( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt new file mode 100644 index 0000000..3b21270 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt @@ -0,0 +1,26 @@ +package com.swoosh.microblog.data.model + +data class TagsResponse( + val tags: List, + val meta: Meta? +) + +data class TagWrapper( + val tags: List +) + +data class GhostTagFull( + val id: String? = null, + val name: String, + val slug: String? = null, + val description: String? = null, + val feature_image: String? = null, + val visibility: String? = "public", + val accent_color: String? = null, + val count: TagCount? = null, + val created_at: String? = null, + val updated_at: String? = null, + val url: String? = null +) + +data class TagCount(val posts: Int?) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt new file mode 100644 index 0000000..83630ef --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt @@ -0,0 +1,196 @@ +package com.swoosh.microblog.data.model + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class TagModelsTest { + + private val gson = Gson() + + // --- GhostTagFull defaults --- + + @Test + fun `GhostTagFull default id is null`() { + val tag = GhostTagFull(name = "test") + assertNull(tag.id) + } + + @Test + fun `GhostTagFull default visibility is public`() { + val tag = GhostTagFull(name = "test") + assertEquals("public", tag.visibility) + } + + @Test + fun `GhostTagFull default optional fields are null`() { + val tag = GhostTagFull(name = "test") + assertNull(tag.slug) + assertNull(tag.description) + assertNull(tag.feature_image) + assertNull(tag.accent_color) + assertNull(tag.count) + assertNull(tag.created_at) + assertNull(tag.updated_at) + assertNull(tag.url) + } + + @Test + fun `GhostTagFull stores all fields`() { + val tag = GhostTagFull( + id = "tag-1", + name = "kotlin", + slug = "kotlin", + description = "Posts about Kotlin", + feature_image = "https://example.com/kotlin.png", + visibility = "public", + accent_color = "#FF5722", + count = TagCount(posts = 42), + created_at = "2024-01-01T00:00:00.000Z", + updated_at = "2024-06-15T12:00:00.000Z", + url = "https://blog.example.com/tag/kotlin/" + ) + assertEquals("tag-1", tag.id) + assertEquals("kotlin", tag.name) + assertEquals("kotlin", tag.slug) + assertEquals("Posts about Kotlin", tag.description) + assertEquals("https://example.com/kotlin.png", tag.feature_image) + assertEquals("public", tag.visibility) + assertEquals("#FF5722", tag.accent_color) + assertEquals(42, tag.count?.posts) + assertEquals("2024-01-01T00:00:00.000Z", tag.created_at) + assertEquals("2024-06-15T12:00:00.000Z", tag.updated_at) + assertEquals("https://blog.example.com/tag/kotlin/", tag.url) + } + + // --- TagCount --- + + @Test + fun `TagCount stores post count`() { + val count = TagCount(posts = 10) + assertEquals(10, count.posts) + } + + @Test + fun `TagCount allows null posts`() { + val count = TagCount(posts = null) + assertNull(count.posts) + } + + // --- GSON serialization --- + + @Test + fun `GhostTagFull serializes to JSON correctly`() { + val tag = GhostTagFull( + name = "android", + description = "Android development", + accent_color = "#3DDC84" + ) + val json = gson.toJson(tag) + assertTrue(json.contains("\"name\":\"android\"")) + assertTrue(json.contains("\"description\":\"Android development\"")) + assertTrue(json.contains("\"accent_color\":\"#3DDC84\"")) + } + + @Test + fun `GhostTagFull deserializes from JSON correctly`() { + val json = """{ + "id": "abc123", + "name": "tech", + "slug": "tech", + "description": "Technology posts", + "visibility": "public", + "accent_color": "#1E88E5", + "count": {"posts": 15}, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-06-01T00:00:00.000Z", + "url": "https://blog.example.com/tag/tech/" + }""" + val tag = gson.fromJson(json, GhostTagFull::class.java) + assertEquals("abc123", tag.id) + assertEquals("tech", tag.name) + assertEquals("tech", tag.slug) + assertEquals("Technology posts", tag.description) + assertEquals("public", tag.visibility) + assertEquals("#1E88E5", tag.accent_color) + assertEquals(15, tag.count?.posts) + assertEquals("2024-01-01T00:00:00.000Z", tag.created_at) + assertEquals("2024-06-01T00:00:00.000Z", tag.updated_at) + assertEquals("https://blog.example.com/tag/tech/", tag.url) + } + + @Test + fun `GhostTagFull deserializes with missing optional fields`() { + val json = """{"name": "minimal"}""" + val tag = gson.fromJson(json, GhostTagFull::class.java) + assertEquals("minimal", tag.name) + assertNull(tag.id) + assertNull(tag.slug) + assertNull(tag.description) + assertNull(tag.accent_color) + assertNull(tag.count) + } + + // --- TagsResponse --- + + @Test + fun `TagsResponse deserializes with tags and meta`() { + val json = """{ + "tags": [ + {"id": "1", "name": "news", "slug": "news", "count": {"posts": 5}}, + {"id": "2", "name": "tech", "slug": "tech", "count": {"posts": 12}} + ], + "meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}} + }""" + val response = gson.fromJson(json, TagsResponse::class.java) + assertEquals(2, response.tags.size) + assertEquals("news", response.tags[0].name) + assertEquals(5, response.tags[0].count?.posts) + assertEquals("tech", response.tags[1].name) + assertEquals(12, response.tags[1].count?.posts) + assertNotNull(response.meta) + assertEquals(1, response.meta?.pagination?.page) + assertEquals(2, response.meta?.pagination?.total) + } + + @Test + fun `TagsResponse deserializes with null meta`() { + val json = """{"tags": [{"name": "solo"}], "meta": null}""" + val response = gson.fromJson(json, TagsResponse::class.java) + assertEquals(1, response.tags.size) + assertNull(response.meta) + } + + // --- TagWrapper --- + + @Test + fun `TagWrapper wraps tags for API request`() { + val wrapper = TagWrapper(listOf(GhostTagFull(name = "new-tag", description = "A new tag"))) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"tags\"")) + assertTrue(json.contains("\"new-tag\"")) + assertTrue(json.contains("\"A new tag\"")) + } + + @Test + fun `TagWrapper serializes accent_color`() { + val wrapper = TagWrapper(listOf(GhostTagFull( + name = "colored", + accent_color = "#FF0000" + ))) + val json = gson.toJson(wrapper) + assertTrue(json.contains("\"accent_color\":\"#FF0000\"")) + } + + @Test + fun `TagCount zero posts`() { + val count = TagCount(posts = 0) + assertEquals(0, count.posts) + } + + @Test + fun `GhostTagFull with internal visibility`() { + val tag = GhostTagFull(name = "internal-tag", visibility = "internal") + assertEquals("internal", tag.visibility) + } +}