From e605f2c779feb8d7d2b028618c977323ab6b8c41 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:06:18 -0700 Subject: [PATCH] feat(materialize): add :core:materialize plumbing for POST /api/materialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Create from…" is invoked from :feature:messages, :feature:lists and :feature:documents, and no feature module in this repo depends on another, so the shared foundation lands in a new :core:materialize module. It owns its DTOs and its own Retrofit API built from the shared authed Retrofit, like every other module here; it has no Room cache, because the endpoint is a one-shot write whose result is authoritative. Modelled from the published API reference (/help/api/create-from) rather than the OpenAPI spec, which types `source` as a bare string: - MaterializeRequest is one sealed type over the whole matrix — four destinations x five source kinds. Each destination carries only the config the API accepts for it, so a doc title cannot ride along on a list-only conversion. A list title is mandatory in the type because the server refuses without one ("A list title is required"). - MaterializeSource is id-only by construction: the server re-fetches and re-authorizes every id and rebuilds from its own data, so there is nowhere for client cell values to be smuggled through. - To Message is a server call that returns a DRAFT and creates nothing; MaterializeTarget.createsContent records the difference so it is not discovered at runtime. - A user-added column sends `sourceKey: null` explicitly (the shared Json runs with explicitNulls = false, which would otherwise drop the key and lose its meaning). Errors map onto the shared AppError so the feature modules keep their existing toUserMessage()/isSubscriptionGate handling. Unlike safeApiCall, a 403 here is the subscriber gate whatever the wording, except for `account_*` codes, which no subscription would lift. MaterializeGate enforces the subscriber check before anything is sent: a free account confirming a creating target fails with SubscriptionRequired and issues no request at all. It fails open — an unreadable customerStatus leaves the server as the real gate rather than locking out a subscriber whose /api/user call happened to fail. No entry points and no UI: those are the sibling issues. :app depends on the module only so its Hilt modules join the component. Tests: 44 new (MockWebServer round-trip per destination asserting the exact body, all five source kinds, every column type, explicit-null sourceKey, free-account confirm with zero requests, error-code mapping). Whole repo green: :app:assembleDebug plus 816 unit tests, 0 failures. Closes #11 --- app/build.gradle.kts | 4 + core/materialize/build.gradle.kts | 52 +++ .../data/DefaultMaterializeRepository.kt | 57 +++ .../materialize/data/MaterializeApiCall.kt | 75 ++++ .../materialize/data/MaterializeRepository.kt | 25 ++ .../data/mapper/MaterializeMappers.kt | 141 +++++++ .../materialize/data/remote/MaterializeApi.kt | 22 ++ .../data/remote/dto/MaterializeRequestDto.kt | 91 +++++ .../data/remote/dto/MaterializeResponseDto.kt | 77 ++++ .../core/materialize/di/MaterializeModule.kt | 35 ++ .../materialize/domain/MaterializeConfigs.kt | 140 +++++++ .../materialize/domain/MaterializeGate.kt | 99 +++++ .../materialize/domain/MaterializeOutcome.kt | 69 ++++ .../materialize/domain/MaterializeRequest.kt | 96 +++++ .../materialize/domain/MaterializeSource.kt | 82 ++++ .../data/MaterializeErrorMappingTest.kt | 160 ++++++++ .../data/MaterializeRequestBodyTest.kt | 370 ++++++++++++++++++ .../data/MaterializeSubscriberGateTest.kt | 162 ++++++++ .../data/MaterializeTestFixtures.kt | 75 ++++ .../domain/MaterializeDomainTest.kt | 87 ++++ .../materialize/domain/MaterializeGateTest.kt | 106 +++++ settings.gradle.kts | 4 + 22 files changed, 2029 insertions(+) create mode 100644 core/materialize/build.gradle.kts create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/DefaultMaterializeRepository.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeApiCall.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRepository.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/mapper/MaterializeMappers.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/MaterializeApi.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeRequestDto.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeResponseDto.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/di/MaterializeModule.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeConfigs.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGate.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeOutcome.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeRequest.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeSource.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeErrorMappingTest.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRequestBodyTest.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeSubscriberGateTest.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeTestFixtures.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeDomainTest.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGateTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5e34fb..c48e04c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,10 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:database")) implementation(project(":core:datastore")) + // Depended on so its Hilt modules join the app component. "Create from…" is + // opened from the messages, lists and documents surfaces, so it has no + // navigation entry of its own here. + implementation(project(":core:materialize")) // Features implementation(project(":feature:auth")) diff --git a/core/materialize/build.gradle.kts b/core/materialize/build.gradle.kts new file mode 100644 index 0000000..1f57cb8 --- /dev/null +++ b/core/materialize/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.core.materialize" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + // `customerStatus` / `CustomerStatus.isSubscriber` — the same subscriber flag + // the rest of the app gates on. + implementation(project(":core:model")) + // ApiResult / AppError / DispatcherProvider. Materialize reuses the shared + // error type so the feature modules can keep their existing + // `AppError.toUserMessage()` / `isSubscriptionGate` helpers. + implementation(project(":core:common")) + // The shared authed Retrofit and `GET /api/user` (read by MaterializeGate). + implementation(project(":core:network")) + + implementation(libs.retrofit.core) + implementation(libs.okhttp.core) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // No Room cache and no Compose: `POST /api/materialize` is a one-shot write + // whose result is authoritative, and the preview/confirm UI is built by the + // feature surfaces that open it. + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.truth) + // The repository tests drive a real Retrofit/OkHttp stack against MockWebServer. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.kotlinx.serialization) +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/DefaultMaterializeRepository.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/DefaultMaterializeRepository.kt new file mode 100644 index 0000000..c2be8b9 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/DefaultMaterializeRepository.kt @@ -0,0 +1,57 @@ +package com.interlinedlist.android.core.materialize.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.data.mapper.toDto +import com.interlinedlist.android.core.materialize.data.mapper.toOutcomeOrNull +import com.interlinedlist.android.core.materialize.data.remote.MaterializeApi +import com.interlinedlist.android.core.materialize.domain.MaterializeGate +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultMaterializeRepository @Inject constructor( + private val api: MaterializeApi, + private val gate: MaterializeGate, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : MaterializeRepository { + + override suspend fun materialize( + request: MaterializeRequest, + ): ApiResult = withContext(dispatchers.io) { + // The gate is the client-side half of the subscriber check and runs + // before anything is sent. It only blocks a target that actually + // creates, and only when the account is positively known to be free: + // an unread status falls through to the server, which is the real gate. + if (request.target.createsContent && gate.ensureResolved().isKnownFree) { + return@withContext ApiResult.Failure( + AppError.SubscriptionRequired(SUBSCRIPTION_MESSAGE), + ) + } + + when (val result = materializeApiCall(json) { api.materialize(request.toDto()) }) { + is ApiResult.Failure -> { + // The server just told us this account cannot create; remember it + // so the next confirm short-circuits to the upsell. + if (result.error is AppError.SubscriptionRequired) gate.recordSubscriptionRequired() + result + } + + is ApiResult.Success -> result.data.toOutcomeOrNull(request) + ?.let { ApiResult.Success(it) } + // A 201 that did not carry what the target promised. + ?: ApiResult.Failure(AppError.Server(EMPTY_RESULT_MESSAGE)) + } + } + + private companion object { + const val SUBSCRIPTION_MESSAGE = + "Creating lists and documents requires an active subscription." + const val EMPTY_RESULT_MESSAGE = + "InterlinedList did not return what it created. Check your lists and documents." + } +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeApiCall.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeApiCall.kt new file mode 100644 index 0000000..33c4f7f --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeApiCall.kt @@ -0,0 +1,75 @@ +package com.interlinedlist.android.core.materialize.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeErrorCode +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeErrorDto +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +/** + * Runs the materialize call and normalises every failure onto the shared + * [AppError], so the feature modules that open this flow keep using the + * `toUserMessage()` / `isSubscriptionGate` helpers they already have. + * + * This is not `safeApiCall`: that helper drops `code` and decides the + * subscription case by looking for the word "subscription" in the message. This + * endpoint is documented as subscriber-only, so **its 403 is the subscriber + * gate** whatever the wording — except when the code says the account itself is + * restricted, suspended or on probation, which no subscription would fix and + * which must therefore not become an upsell. + */ +internal suspend fun materializeApiCall( + json: Json, + block: suspend () -> T, +): ApiResult = try { + ApiResult.Success(block()) +} catch (e: HttpException) { + ApiResult.Failure(e.toAppError(json)) +} catch (e: IOException) { + ApiResult.Failure(AppError.Network(e.message)) +} catch (e: Exception) { + ApiResult.Failure(AppError.Unknown(e.message)) +} + +private fun HttpException.toAppError(json: Json): AppError { + val body = runCatching { response()?.errorBody()?.string() }.getOrNull() + val dto = body + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { json.decodeFromString(MaterializeErrorDto.serializer(), it) }.getOrNull() } + val message = dto?.error + val code = dto?.code + + return when { + // An account-status refusal is a 403 a subscription would not lift. + code?.startsWith(MaterializeErrorCode.ACCOUNT_PREFIX) == true -> AppError.Forbidden(message) + code == MaterializeErrorCode.SUBSCRIPTION_REQUIRED -> AppError.SubscriptionRequired(message) + code == MaterializeErrorCode.UNAUTHORIZED -> AppError.Unauthorized(message) + code == MaterializeErrorCode.NOT_FOUND -> AppError.NotFound(message) + code == MaterializeErrorCode.RATE_LIMITED -> AppError.RateLimited(message) + // `bad_request` / `validation_failed` carry a message worth showing + // verbatim ("Missing source", "Field 'year' has invalid type …"); the + // shared error type has no validation case, and `Unknown` renders the + // server's own words in every feature's `toUserMessage()`. + code == MaterializeErrorCode.BAD_REQUEST || + code == MaterializeErrorCode.VALIDATION_FAILED -> AppError.Unknown(message) + code == MaterializeErrorCode.INTERNAL_ERROR -> AppError.Server(message) + // `code` is optional on the wire — fall back to the status. + else -> fromStatus(code(), message) + } +} + +/** Fallback for a response that carried no `code`. */ +private fun fromStatus(status: Int, message: String?): AppError = when (status) { + 401 -> AppError.Unauthorized(message) + // The only 403 this endpoint documents is the subscriber gate. + 403 -> AppError.SubscriptionRequired(message) + // "A referenced id is not found or not owned by you." + 404 -> AppError.NotFound(message) + 409 -> AppError.Conflict(message) + 429 -> AppError.RateLimited(message) + in 500..599 -> AppError.Server(message) + // Includes the documented 400: show the server's own explanation. + else -> AppError.Unknown(message ?: "HTTP $status") +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRepository.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRepository.kt new file mode 100644 index 0000000..d870d01 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRepository.kt @@ -0,0 +1,25 @@ +package com.interlinedlist.android.core.materialize.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest + +/** + * `POST /api/materialize` — the whole "Create from…" surface, in one call. + * + * There is nothing to cache: the endpoint is a one-shot write whose result is + * authoritative, so this repository has no Room database and no offline read. + */ +interface MaterializeRepository { + + /** + * Runs a confirmed conversion. + * + * Enforces the subscriber gate first: when the account is positively known + * to be free and [request] would create something, this fails with + * `AppError.SubscriptionRequired` **without issuing the request**, so a free + * account cannot trigger a write. The menu that leads here is open to + * everyone; only this confirmation is gated. + */ + suspend fun materialize(request: MaterializeRequest): ApiResult +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/mapper/MaterializeMappers.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/mapper/MaterializeMappers.kt new file mode 100644 index 0000000..c2f19f6 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/mapper/MaterializeMappers.kt @@ -0,0 +1,141 @@ +package com.interlinedlist.android.core.materialize.data.mapper + +import com.interlinedlist.android.core.materialize.data.remote.dto.DocConfigDto +import com.interlinedlist.android.core.materialize.data.remote.dto.ListConfigDto +import com.interlinedlist.android.core.materialize.data.remote.dto.ListFieldDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeRequestDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeResponseDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeSourceDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializedDocumentDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializedListDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MessageConfigDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MessageDraftDto +import com.interlinedlist.android.core.materialize.domain.DocConfig +import com.interlinedlist.android.core.materialize.domain.ListConfig +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializedDocument +import com.interlinedlist.android.core.materialize.domain.MaterializedList +import com.interlinedlist.android.core.materialize.domain.MessageDraft +import com.interlinedlist.android.core.materialize.domain.MessageDraftConfig +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +/** + * Builds the request body. Each destination contributes only its own config, so + * the body never carries a section the target ignores. + */ +internal fun MaterializeRequest.toDto(): MaterializeRequestDto = MaterializeRequestDto( + target = target.apiValue, + source = source.toDto(), + listConfig = when (this) { + is MaterializeRequest.ToList -> listConfig.toDto() + is MaterializeRequest.ToListAndDocument -> listConfig.toDto() + else -> null + }, + docConfig = when (this) { + is MaterializeRequest.ToDocument -> docConfig?.toDto() + is MaterializeRequest.ToListAndDocument -> docConfig?.toDto() + else -> null + }, + messageConfig = (this as? MaterializeRequest.ToMessageDraft)?.messageConfig?.toDto(), +) + +/** Ids only — see [MaterializeSource]. */ +internal fun MaterializeSource.toDto(): MaterializeSourceDto = when (this) { + is MaterializeSource.Messages -> MaterializeSourceDto(kind = kind, messageIds = messageIds) + is MaterializeSource.Lists -> MaterializeSourceDto(kind = kind, listIds = listIds) + is MaterializeSource.Rows -> MaterializeSourceDto(kind = kind, listId = listId, rowIds = rowIds) + is MaterializeSource.Document -> MaterializeSourceDto(kind = kind, documentId = documentId) + is MaterializeSource.DocumentSelection -> + MaterializeSourceDto(kind = kind, documentId = documentId, markdown = markdown) +} + +internal fun ListConfig.toDto(): ListConfigDto = ListConfigDto( + title = title, + description = description, + isPublic = isPublic, + fields = fields?.map { it.toDto() }, + includeData = includeData, +) + +internal fun MaterializeColumn.toDto(): ListFieldDto = ListFieldDto( + propertyKey = propertyKey, + propertyName = propertyName, + propertyType = propertyType.apiValue, + // Explicit `null` — a user-added empty column, not an omitted key. + sourceKey = sourceKey?.let { JsonPrimitive(it) } ?: JsonNull, + isRequired = isRequired, + options = options, +) + +internal fun DocConfig.toDto(): DocConfigDto = DocConfigDto( + title = title, + relativePath = relativePath, + isPublic = isPublic, + listStyle = listStyle?.apiValue, + rowDataStyle = rowDataStyle?.apiValue, +) + +internal fun MessageDraftConfig.toDto(): MessageConfigDto = MessageConfigDto( + content = content, + crossPostTargets = crossPostTargets?.map { it.apiValue }, + allowThread = allowThread, + publiclyVisible = publiclyVisible, + tags = tags, + scheduledAt = scheduledAt, +) + +/** + * Reads the result against the target that was asked for. + * + * Returns null when the 201 did not carry what the target promised — a contract + * violation the caller has to see as a failure rather than as an empty success. + */ +internal fun MaterializeResponseDto.toOutcomeOrNull( + request: MaterializeRequest, +): MaterializeOutcome? = when (request) { + is MaterializeRequest.ToList -> + list?.toDomain()?.let { MaterializeOutcome.ListCreated(it) } + + is MaterializeRequest.ToDocument -> + document?.toDomain()?.let { MaterializeOutcome.DocumentCreated(it) } + + is MaterializeRequest.ToListAndDocument -> { + val createdList = list?.toDomain() + val createdDocument = document?.toDomain() + if (createdList != null && createdDocument != null) { + MaterializeOutcome.ListAndDocumentCreated(createdList, createdDocument) + } else { + null + } + } + + is MaterializeRequest.ToMessageDraft -> + message?.toDomain()?.let { MaterializeOutcome.DraftReady(it) } +} + +/** The id is the part that matters — a titleless response still opens. */ +internal fun MaterializedListDto.toDomain(): MaterializedList = MaterializedList( + id = id, + title = title.orEmpty(), + description = description, + isPublic = isPublic, +) + +internal fun MaterializedDocumentDto.toDomain(): MaterializedDocument = MaterializedDocument( + id = id, + title = title.orEmpty(), + relativePath = relativePath, + isPublic = isPublic, +) + +internal fun MessageDraftDto.toDomain(): MessageDraft = MessageDraft( + content = content, + // A server that only sent `content` still yields a one-part draft. + thread = thread.ifEmpty { listOf(content) }, + isThread = isThread, + charLimit = charLimit, +) diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/MaterializeApi.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/MaterializeApi.kt new file mode 100644 index 0000000..a7a1cbe --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/MaterializeApi.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.core.materialize.data.remote + +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeRequestDto +import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeResponseDto +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit description of `POST /api/materialize`, built from the shared authed + * Retrofit (base URL and `Authorization: Bearer …` already applied). + * + * One endpoint backs all four destinations; the `target` in the body chooses. + */ +interface MaterializeApi { + + /** + * Creates a list, a document, or both from an id-only source — or, for + * `target: "message"`, returns a draft and creates nothing. + */ + @POST("api/materialize") + suspend fun materialize(@Body request: MaterializeRequestDto): MaterializeResponseDto +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeRequestDto.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeRequestDto.kt new file mode 100644 index 0000000..c1f09a3 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeRequestDto.kt @@ -0,0 +1,91 @@ +package com.interlinedlist.android.core.materialize.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * `POST /api/materialize` body. + * + * ``` + * { "target": "list|doc|both|message", + * "source": { "kind": "messages", "messageIds": ["clx…"] }, + * "listConfig": { … }, "docConfig": { … }, "messageConfig": { … } } + * ``` + * + * `target` and `source` are the only required members; the shared Json is + * configured with `explicitNulls = false`, so an absent config is simply left + * out of the body and the server applies its own defaults. + */ +@Serializable +data class MaterializeRequestDto( + val target: String, + val source: MaterializeSourceDto, + val listConfig: ListConfigDto? = null, + val docConfig: DocConfigDto? = null, + val messageConfig: MessageConfigDto? = null, +) + +/** + * The id-only source descriptor. One DTO covers all five kinds because the + * wire format is a flat object discriminated by `kind`; which id fields are + * populated is decided by the domain [com.interlinedlist.android.core.materialize.domain.MaterializeSource] + * case, so an invalid combination cannot be built here. + */ +@Serializable +data class MaterializeSourceDto( + val kind: String, + val messageIds: List? = null, + val listIds: List? = null, + val listId: String? = null, + val rowIds: List? = null, + val documentId: String? = null, + /** Only for `docElements`: the highlighted passage is the selection's identity. */ + val markdown: String? = null, +) + +@Serializable +data class ListConfigDto( + val title: String? = null, + val description: String? = null, + val isPublic: Boolean? = null, + val fields: List? = null, + val includeData: Boolean? = null, +) + +/** + * One column definition. + * + * [sourceKey] is a [JsonElement] rather than a `String?` on purpose: a null + * `sourceKey` is *meaningful* — it marks a user-added empty column — but the + * shared Json runs with `explicitNulls = false`, which would drop a null + * property from the body entirely. Holding `JsonNull` in a non-nullable + * property forces the key to be written as an explicit `null`. + */ +@Serializable +data class ListFieldDto( + val propertyKey: String, + val propertyName: String, + val propertyType: String, + val sourceKey: JsonElement, + val isRequired: Boolean? = null, + val options: List? = null, +) + +@Serializable +data class DocConfigDto( + val title: String? = null, + val relativePath: String? = null, + val isPublic: Boolean? = null, + val listStyle: String? = null, + val rowDataStyle: String? = null, +) + +@Serializable +data class MessageConfigDto( + val content: String? = null, + val crossPostTargets: List? = null, + val allowThread: Boolean? = null, + val publiclyVisible: Boolean? = null, + val tags: List? = null, + val scheduledAt: String? = null, +) diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeResponseDto.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeResponseDto.kt new file mode 100644 index 0000000..db86279 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/data/remote/dto/MaterializeResponseDto.kt @@ -0,0 +1,77 @@ +package com.interlinedlist.android.core.materialize.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `201 Created` from `POST /api/materialize`. + * + * Which members are present follows the requested `target`: `list` returns + * `list`, `doc` returns `document`, `both` returns both, and `message` returns + * `message` — a draft that was not posted. Everything is nullable here because + * the presence rule belongs to the mapper, which knows which target was asked + * for; a 201 that omits what the target promised is a contract violation, not a + * shape this DTO should pretend to model. + */ +@Serializable +data class MaterializeResponseDto( + val list: MaterializedListDto? = null, + val document: MaterializedDocumentDto? = null, + val message: MessageDraftDto? = null, +) + +/** + * The published example returns only `id` and `title`; the schema shows the + * full list row. Everything past `id` is optional so either answer parses. + */ +@Serializable +data class MaterializedListDto( + val id: String, + val title: String? = null, + val description: String? = null, + val isPublic: Boolean? = null, +) + +@Serializable +data class MaterializedDocumentDto( + val id: String, + val title: String? = null, + val relativePath: String? = null, + val isPublic: Boolean? = null, +) + +/** The draft built for `target: "message"`. All four members are documented as required. */ +@Serializable +data class MessageDraftDto( + val content: String = "", + val thread: List = emptyList(), + val isThread: Boolean = false, + val charLimit: Int = 0, +) + +/** + * The API's error envelope, `{ "error": "…", "code": "…" }`. + * + * The shared `ErrorDto` in `:core:network` drops `code`, and this endpoint needs + * it: its 403 is the subscriber gate except when the code says the account is + * restricted/suspended/on probation, which a subscription would not fix. + */ +@Serializable +data class MaterializeErrorDto( + val error: String? = null, + val code: String? = null, +) + +/** The documented machine-readable codes this endpoint can answer with. */ +internal object MaterializeErrorCode { + const val UNAUTHORIZED = "unauthorized" + const val FORBIDDEN = "forbidden" + const val SUBSCRIPTION_REQUIRED = "subscription_required" + const val BAD_REQUEST = "bad_request" + const val VALIDATION_FAILED = "validation_failed" + const val NOT_FOUND = "not_found" + const val RATE_LIMITED = "rate_limited" + const val INTERNAL_ERROR = "internal_error" + + /** `account_restricted`, `account_suspended`, `account_probation_feature`, … */ + const val ACCOUNT_PREFIX = "account_" +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/di/MaterializeModule.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/di/MaterializeModule.kt new file mode 100644 index 0000000..0ad74e4 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/di/MaterializeModule.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.core.materialize.di + +import com.interlinedlist.android.core.materialize.data.DefaultMaterializeRepository +import com.interlinedlist.android.core.materialize.data.MaterializeRepository +import com.interlinedlist.android.core.materialize.data.remote.MaterializeApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class MaterializeRepositoryModule { + + @Binds + @Singleton + abstract fun bindMaterializeRepository( + impl: DefaultMaterializeRepository, + ): MaterializeRepository +} + +/** Provides the materialize API off the shared authed Retrofit. */ +@Module +@InstallIn(SingletonComponent::class) +object MaterializeDataModule { + + @Provides + @Singleton + fun provideMaterializeApi(retrofit: Retrofit): MaterializeApi = + retrofit.create(MaterializeApi::class.java) +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeConfigs.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeConfigs.kt new file mode 100644 index 0000000..720b39b --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeConfigs.kt @@ -0,0 +1,140 @@ +package com.interlinedlist.android.core.materialize.domain + +/** + * What the preview/edit step may change about the list a conversion produces. + * + * [title] is mandatory: the server validates it right after the target and + * answers `{"error":"A list title is required","code":"bad_request"}` without + * it, so requiring it here turns a guaranteed round-trip failure into something + * that will not compile. Everything else is optional; omitting a field lets the + * server derive its own default from the source (messages default to + * Content / Author / Posted / Links / Tags, a document defaults to one row per + * heading and bullet). + */ +data class ListConfig( + val title: String, + val description: String? = null, + val isPublic: Boolean? = null, + /** Column definitions. Null leaves the server's derived columns alone. */ + val fields: List? = null, + /** + * Seed the new list with rows derived from the source. The server defaults + * to true; set false for an empty, columns-only list. + */ + val includeData: Boolean? = null, +) { + init { require(title.isNotBlank()) { "A list title is required" } } +} + +/** + * One column of the list a conversion produces. + * + * [sourceKey] is what makes this safe: it names the source attribute the column + * takes its values from, and the server re-derives every cell from that key. It + * does not accept client cell values. A **null** [sourceKey] means a user-added + * empty column, and is sent as an explicit JSON `null` rather than being omitted. + */ +data class MaterializeColumn( + val propertyKey: String, + val propertyName: String, + val propertyType: ListColumnType, + val sourceKey: String? = null, + val isRequired: Boolean? = null, + /** Allowed values; required by [ListColumnType.SELECT] and [ListColumnType.MULTISELECT]. */ + val options: List? = null, +) + +/** + * The twelve column types the list schema accepts. The server rejects anything + * else with `bad_request`, so the picker in the preview step chooses from here + * rather than from free text. + */ +enum class ListColumnType(val apiValue: String) { + TEXT("text"), + TEXTAREA("textarea"), + NUMBER("number"), + BOOLEAN("boolean"), + DATE("date"), + DATETIME("datetime"), + EMAIL("email"), + URL("url"), + TEL("tel"), + SELECT("select"), + MULTISELECT("multiselect"), + PRIORITY("priority"); + + companion object { + /** Maps a wire string (or null) back to a type, or null when unknown. */ + fun fromApiValue(value: String?): ListColumnType? = + entries.firstOrNull { it.apiValue == value } + } +} + +/** + * What the preview/edit step may change about the document a conversion + * produces. [listStyle] and [rowDataStyle] only bite when the source is a list + * or a set of rows. + * + * Unlike [ListConfig.title], a document title is documented as optional and has + * not been observed to be required, so it stays nullable and the server derives + * one from the source when it is omitted. + */ +data class DocConfig( + val title: String? = null, + /** Folder path / file name for the new document. */ + val relativePath: String? = null, + val isPublic: Boolean? = null, + val listStyle: DocumentListStyle? = null, + val rowDataStyle: RowDataStyle? = null, +) + +/** How list/row sources render as document bullets. */ +enum class DocumentListStyle(val apiValue: String) { + NUMBERED("numbered"), + BULLETED("bulleted"), +} + +/** How each row's fields are laid out under its headline. */ +enum class RowDataStyle(val apiValue: String) { + INLINE("inline"), + SUB_ITEMS("sub-items"), +} + +/** + * What the preview/edit step may change about the **draft** the message + * destination returns. Nothing here is posted: `publiclyVisible`, `tags` and + * `scheduledAt` are accepted and handed straight back so the composer can apply + * them, and every posting rule is enforced later by `POST /api/messages`. + */ +data class MessageDraftConfig( + /** The edited body. Omitted, the server derives one from the source. */ + val content: String? = null, + /** Sizes the draft: the tightest selected channel wins over the account limit. */ + val crossPostTargets: List? = null, + /** + * Permission to split an over-length body into a thread. The server rejects + * the request rather than splitting someone's post unasked. + */ + val allowThread: Boolean? = null, + val publiclyVisible: Boolean? = null, + val tags: List? = null, + /** ISO-8601 instant, passed through to the composer. */ + val scheduledAt: String? = null, +) + +/** + * A cross-post channel, used only to size the draft's `charLimit`. + * + * The published reference names these as labels ("Bluesky, Mastodon, LinkedIn, + * X/Twitter") without pinning the wire spelling; these values match the + * `platform` field the API documents on a created message's `crossPosts` array, + * which is the only place the spelling is stated. If the server turns out to + * want display labels, only the draft's character budget is affected — nothing + * is created by this destination. + */ +enum class CrossPostChannel(val apiValue: String) { + BLUESKY("bluesky"), + MASTODON("mastodon"), + LINKEDIN("linkedin"), + TWITTER("twitter"), +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGate.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGate.kt new file mode 100644 index 0000000..44ad9ce --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGate.kt @@ -0,0 +1,99 @@ +package com.interlinedlist.android.core.materialize.domain + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.dto.toDomain +import com.interlinedlist.android.core.network.error.safeApiCall +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** Whether this account may confirm a conversion that creates something. */ +enum class MaterializeAccess { + /** Not read yet, or unreadable (offline, 401). */ + UNKNOWN, + + /** Positively known to be on the free plan. */ + FREE, + + /** Positively known to have an active subscription. */ + SUBSCRIBER; + + /** True only when we positively know the account cannot create. */ + val isKnownFree: Boolean get() = this == FREE +} + +/** + * The subscriber gate for "Create from…". + * + * The menu opens for everyone — the gate is deliberately **not** consulted when + * drawing an entry point. It is consulted when a conversion is confirmed, and + * only to stop a free account issuing a write it is certain to be refused; + * `DefaultMaterializeRepository` turns that into + * `AppError.SubscriptionRequired`, which the feature modules already render as + * an upsell through their existing `isSubscriptionGate` handling. + * + * It fails **open**: an unreadable status leaves [access] `UNKNOWN` and the + * request goes to the server, which is the real gate. Blocking on a status we + * could not read would lock out a paying subscriber whose `/api/user` call + * happened to fail. + * + * Application-scoped so every entry point shares one `/api/user` read. + */ +@Singleton +class MaterializeGate @Inject constructor( + private val userApi: InterlinedListApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) { + + private val _access = MutableStateFlow(MaterializeAccess.UNKNOWN) + + /** Observable for surfaces that want to pre-badge the menu. Never hides it. */ + val access: StateFlow = _access.asStateFlow() + + /** Re-reads `customerStatus` from `GET /api/user`. */ + suspend fun refresh(): MaterializeAccess = withContext(dispatchers.io) { + val resolved = when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) { + is ApiResult.Success -> result.data.toDomain().customerStatus.toAccess() + // Unreadable: stay UNKNOWN and let the server decide. + is ApiResult.Failure -> MaterializeAccess.UNKNOWN + } + _access.value = resolved + resolved + } + + /** Resolves the status once; a no-op once it is known either way. */ + suspend fun ensureResolved(): MaterializeAccess = + if (_access.value == MaterializeAccess.UNKNOWN) refresh() else _access.value + + /** + * Folds a `customerStatus` another surface already loaded into the gate, so + * a confirm does not re-read `/api/user` the app has just fetched. + */ + fun record(status: CustomerStatus) { + _access.value = status.toAccess() + } + + /** + * Applies what a refused conversion revealed: the server answered + * "subscriber only", so subsequent confirms short-circuit to the upsell + * instead of issuing another write that will be refused. + */ + fun recordSubscriptionRequired() { + _access.value = MaterializeAccess.FREE + } + + private fun CustomerStatus.toAccess(): MaterializeAccess = when { + isSubscriber -> MaterializeAccess.SUBSCRIBER + this == CustomerStatus.FREE -> MaterializeAccess.FREE + // An unrecognised tier is not evidence the account is free. + else -> MaterializeAccess.UNKNOWN + } +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeOutcome.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeOutcome.kt new file mode 100644 index 0000000..d5d42ce --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeOutcome.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.core.materialize.domain + +/** + * What a conversion produced. + * + * **This is authoritative, not an echo of the preview.** Only ids went over the + * wire; the server rebuilt everything from its own data, so a result may + * legitimately differ from what was previewed if the source changed mid-flow. + * Render what came back. + * + * One case per destination, so a caller reads the created objects without + * null-checking keys the target never promised. + */ +sealed interface MaterializeOutcome { + + /** `To List`. */ + data class ListCreated(val list: MaterializedList) : MaterializeOutcome + + /** `To Doc`. */ + data class DocumentCreated(val document: MaterializedDocument) : MaterializeOutcome + + /** `To List & Doc`. */ + data class ListAndDocumentCreated( + val list: MaterializedList, + val document: MaterializedDocument, + ) : MaterializeOutcome + + /** + * `To Message`. Nothing was created — hand [draft] to the composer, which + * posts it through `POST /api/messages`. + */ + data class DraftReady(val draft: MessageDraft) : MaterializeOutcome +} + +/** + * The list a conversion created. Only [id] and [title] are guaranteed by the + * published example; the rest are modelled defensively as nullable so a thinner + * response still parses and the caller can at least open what was made. + */ +data class MaterializedList( + val id: String, + val title: String, + val description: String? = null, + val isPublic: Boolean? = null, +) + +/** The document a conversion created. Nullable beyond [id]/[title] for the same reason. */ +data class MaterializedDocument( + val id: String, + val title: String, + val relativePath: String? = null, + val isPublic: Boolean? = null, +) + +/** + * The body the message destination built. **Nothing has been posted.** + * + * [charLimit] is the smaller of the caller's own `maxMessageLength` and the + * tightest selected cross-post channel, so the composer shows the limit that + * will actually apply rather than previewing one post and silently sending a + * three-part thread. + */ +data class MessageDraft( + val content: String, + /** The body split to [charLimit], in reply order. One entry when it already fits. */ + val thread: List, + val isThread: Boolean, + val charLimit: Int, +) diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeRequest.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeRequest.kt new file mode 100644 index 0000000..e9c022e --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeRequest.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.core.materialize.domain + +/** + * The `target` discriminator `POST /api/materialize` requires. This is the one + * place the wire strings live. + */ +enum class MaterializeTarget(val apiValue: String) { + /** A new list, one row per item. */ + LIST("list"), + + /** A new markdown document. */ + DOC("doc"), + + /** Both, in a single step. */ + BOTH("both"), + + /** + * A message **draft**. This target creates nothing: it is still a server + * call — the server builds the body from the source and sizes it — but the + * response is a draft that the composer must post through + * `POST /api/messages`, which is where every posting gate lives. + */ + MESSAGE("message"); + + /** + * True for the three targets that write. The subscriber gate applies to + * these; see `MaterializeGate`. + */ + val createsContent: Boolean get() = this != MESSAGE +} + +/** + * One confirmed "Create from…" conversion: a [MaterializeSource] plus the + * destination it is going to, carrying **only** the configuration that + * destination accepts. + * + * The four cases are the four destinations offered on every entry point. Each + * one owns its own config, so a document title cannot be attached to a + * list-only conversion and a cross-post channel cannot be attached to something + * that is not a draft — the request/target/config matrix is closed by the type + * rather than validated at runtime. + * + * Note that [ToMessageDraft] is *not* a client-side prefill: it is the same + * endpoint, and the server derives and sizes the body. What differs is that it + * returns a [MaterializeOutcome.DraftReady] and persists nothing, which is why + * [MaterializeTarget.createsContent] is false for it. + * + * Every combination of source and destination the API accepts is representable. + * The only combination the product hides is messages → message, which the web + * UI omits in favour of Quote/Push; the API accepts it, so it is not excluded + * here — an entry point simply does not offer it. + */ +sealed interface MaterializeRequest { + + val source: MaterializeSource + val target: MaterializeTarget + + /** + * `To List` — a new list, one row per item. [listConfig] is not optional: + * the server requires a list title. + */ + data class ToList( + override val source: MaterializeSource, + val listConfig: ListConfig, + ) : MaterializeRequest { + override val target: MaterializeTarget get() = MaterializeTarget.LIST + } + + /** `To Doc` — a new markdown document. */ + data class ToDocument( + override val source: MaterializeSource, + val docConfig: DocConfig? = null, + ) : MaterializeRequest { + override val target: MaterializeTarget get() = MaterializeTarget.DOC + } + + /** + * `To List & Doc` — both, in one step, from one source. The list half still + * needs its title; the document half can be left to the server. + */ + data class ToListAndDocument( + override val source: MaterializeSource, + val listConfig: ListConfig, + val docConfig: DocConfig? = null, + ) : MaterializeRequest { + override val target: MaterializeTarget get() = MaterializeTarget.BOTH + } + + /** `To Message` — returns a draft for the composer and creates nothing. */ + data class ToMessageDraft( + override val source: MaterializeSource, + val messageConfig: MessageDraftConfig? = null, + ) : MaterializeRequest { + override val target: MaterializeTarget get() = MaterializeTarget.MESSAGE + } +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeSource.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeSource.kt new file mode 100644 index 0000000..5d4a369 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeSource.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.core.materialize.domain + +/** + * What a "Create from…" flow is converting, as an **id-only** reference. + * + * `POST /api/materialize` deliberately accepts nothing but ids: the server + * re-fetches and re-authorizes every referenced id under the calling user and + * rebuilds the source from its own data, so client-supplied cell values or body + * text are never trusted. Referencing something the caller does not own is a + * 404. Modelling the source as ids only makes it impossible for a caller to + * smuggle content through this endpoint by accident. + * + * [DocumentSelection] is the one exception, and only because the selection has + * no id of its own: the highlighted markdown is the selection's identity, and + * the server still re-authorizes the document it came from. + * + * Each case carries exactly the ids its `kind` requires, so a `rows` source + * without its owning `listId` — or a `document` source carrying message ids — + * cannot be constructed. + */ +sealed interface MaterializeSource { + + /** The `source.kind` discriminator this case sends. */ + val kind: String + + /** One or more messages. Several messages combine into a single result. */ + data class Messages(val messageIds: List) : MaterializeSource { + init { require(messageIds.isNotEmpty()) { "A messages source needs at least one message id" } } + + override val kind: String get() = KIND + + companion object { const val KIND: String = "messages" } + } + + /** One or more whole lists — schema and rows. */ + data class Lists(val listIds: List) : MaterializeSource { + init { require(listIds.isNotEmpty()) { "A lists source needs at least one list id" } } + + override val kind: String get() = KIND + + companion object { const val KIND: String = "lists" } + } + + /** Selected rows from a single list; the rows cannot be separated from their list. */ + data class Rows(val listId: String, val rowIds: List) : MaterializeSource { + init { + require(listId.isNotBlank()) { "A rows source needs the id of the list it came from" } + require(rowIds.isNotEmpty()) { "A rows source needs at least one row id" } + } + + override val kind: String get() = KIND + + companion object { const val KIND: String = "rows" } + } + + /** A whole document. */ + data class Document(val documentId: String) : MaterializeSource { + init { require(documentId.isNotBlank()) { "A document source needs a document id" } } + + override val kind: String get() = KIND + + companion object { const val KIND: String = "document" } + } + + /** + * A highlighted passage of a document, identified by the document id plus the + * selected markdown. The wire `kind` is `docElements`. + */ + data class DocumentSelection( + val documentId: String, + val markdown: String, + ) : MaterializeSource { + init { + require(documentId.isNotBlank()) { "A document selection needs a document id" } + require(markdown.isNotBlank()) { "A document selection needs the selected markdown" } + } + + override val kind: String get() = KIND + + companion object { const val KIND: String = "docElements" } + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeErrorMappingTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeErrorMappingTest.kt new file mode 100644 index 0000000..da9490e --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeErrorMappingTest.kt @@ -0,0 +1,160 @@ +package com.interlinedlist.android.core.materialize.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.domain.ListConfig +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.model.CustomerStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * `{ "error": …, "code": … }` onto the shared `AppError`, so the feature modules + * that open this flow keep using their existing `toUserMessage()` and + * `isSubscriptionGate` helpers. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaterializeErrorMappingTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private val request = MaterializeRequest.ToList( + source = MaterializeSource.Messages(listOf("msg_1")), + listConfig = ListConfig(title = "Launch notes"), + ) + + private suspend fun errorFor(response: MockResponse): AppError { + server.enqueue(response) + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.SUBSCRIBER) } + val result = repositoryFor(server, dispatcher, gate).materialize(request) + return (result as ApiResult.Failure).error + } + + @Test + fun `401 unauthorized maps to Unauthorized`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse(401, """{ "error": "Unauthorized", "code": "unauthorized" }"""), + ) + assertThat(error).isInstanceOf(AppError.Unauthorized::class.java) + assertThat(error.message).isEqualTo("Unauthorized") + } + + @Test + fun `403 subscription_required maps to SubscriptionRequired`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse( + 403, + """{ "error": "This feature is for subscribers", "code": "subscription_required" }""", + ), + ) + assertThat(error).isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `a bare 403 is still the subscriber gate - this endpoint documents no other`() = + runTest(dispatcher) { + // `code` is optional on the wire and some routes omit it. The + // shared safeApiCall would need the word "subscription" in the + // message to reach the upsell; here the status is enough. + val error = errorFor(jsonResponse(403, """{ "error": "Forbidden" }""")) + assertThat(error).isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `an account_ code is Forbidden, not an upsell`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse( + 403, + """{ "error": "Your account is suspended", "code": "account_suspended" }""", + ), + ) + // Subscribing would not lift this, so it must not be sold as an upgrade. + assertThat(error).isInstanceOf(AppError.Forbidden::class.java) + } + + @Test + fun `404 maps to NotFound - a referenced id is missing or not owned`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse(404, """{ "error": "List not found", "code": "not_found" }"""), + ) + assertThat(error).isInstanceOf(AppError.NotFound::class.java) + assertThat(error.message).isEqualTo("List not found") + } + + @Test + fun `400 bad_request keeps the server's own explanation`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse(400, """{ "error": "A list title is required", "code": "bad_request" }"""), + ) + // The shared AppError has no validation case; the message is what the + // user needs, and every feature's toUserMessage() renders it verbatim. + assertThat(error).isInstanceOf(AppError.Unknown::class.java) + assertThat(error.message).isEqualTo("A list title is required") + } + + @Test + fun `validation_failed keeps its message too`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse( + 400, + """{ "error": "Field 'year' has invalid type", "code": "validation_failed" }""", + ), + ) + assertThat(error.message).isEqualTo("Field 'year' has invalid type") + } + + @Test + fun `500 maps to Server`() = runTest(dispatcher) { + val error = errorFor( + jsonResponse(500, """{ "error": "Internal error", "code": "internal_error" }"""), + ) + assertThat(error).isInstanceOf(AppError.Server::class.java) + } + + @Test + fun `a dropped connection maps to Network`() = runTest(dispatcher) { + val error = errorFor( + MockResponse().apply { socketPolicy = SocketPolicy.DISCONNECT_AT_START }, + ) + assertThat(error).isInstanceOf(AppError.Network::class.java) + } + + @Test + fun `a 201 that created nothing is a failure, not an empty success`() = runTest(dispatcher) { + val error = errorFor(jsonResponse(201, "{}")) + assertThat(error).isInstanceOf(AppError.Server::class.java) + } + + @Test + fun `a both target that came back with only a list is a failure`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_1", "title": "t" } }""")) + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.SUBSCRIBER) } + + val result = repositoryFor(server, dispatcher, gate).materialize( + MaterializeRequest.ToListAndDocument( + source = MaterializeSource.Messages(listOf("msg_1")), + listConfig = ListConfig(title = "t"), + ), + ) + + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Server::class.java) + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRequestBodyTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRequestBodyTest.kt new file mode 100644 index 0000000..8f0b5ba --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeRequestBodyTest.kt @@ -0,0 +1,370 @@ +package com.interlinedlist.android.core.materialize.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.domain.CrossPostChannel +import com.interlinedlist.android.core.materialize.domain.DocConfig +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.ListConfig +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MessageDraftConfig +import com.interlinedlist.android.core.materialize.domain.RowDataStyle +import com.interlinedlist.android.core.model.CustomerStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * One MockWebServer round-trip per destination, asserting the exact body + * `POST /api/materialize` receives, plus the serialisation of every source kind. + * + * The gate is pre-resolved to a subscriber in each test so the only request on + * the wire is the materialize call itself. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaterializeRequestBodyTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private fun subscriberRepository(): DefaultMaterializeRepository { + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.SUBSCRIBER) } + return repositoryFor(server, dispatcher, gate) + } + + private val messages = MaterializeSource.Messages(listOf("msg_1", "msg_2")) + + // ---------------------------------------------------------------- targets + + @Test + fun `To List posts target list with the list config only`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_x9f2", "title": "Launch notes" } }""")) + + val result = subscriberRepository().materialize( + MaterializeRequest.ToList( + source = messages, + listConfig = ListConfig( + title = "Launch notes", + description = "My reading backlog.", + isPublic = false, + includeData = true, + fields = listOf( + MaterializeColumn( + propertyKey = "content", + propertyName = "Content", + propertyType = ListColumnType.TEXTAREA, + sourceKey = "content", + ), + ), + ), + ), + ) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/materialize") + + val body = request.jsonBody() + assertThat(body["target"]?.jsonPrimitive?.content).isEqualTo("list") + assertThat(body["docConfig"]).isNull() + assertThat(body["messageConfig"]).isNull() + + val listConfig = body["listConfig"]!!.jsonObject + assertThat(listConfig["title"]?.jsonPrimitive?.content).isEqualTo("Launch notes") + assertThat(listConfig["description"]?.jsonPrimitive?.content).isEqualTo("My reading backlog.") + assertThat(listConfig["isPublic"]?.jsonPrimitive?.content).isEqualTo("false") + assertThat(listConfig["includeData"]?.jsonPrimitive?.content).isEqualTo("true") + + val field = listConfig["fields"]!!.jsonArray.single().jsonObject + assertThat(field["propertyKey"]?.jsonPrimitive?.content).isEqualTo("content") + assertThat(field["propertyName"]?.jsonPrimitive?.content).isEqualTo("Content") + assertThat(field["propertyType"]?.jsonPrimitive?.content).isEqualTo("textarea") + assertThat(field["sourceKey"]?.jsonPrimitive?.content).isEqualTo("content") + + val outcome = (result as ApiResult.Success).data as MaterializeOutcome.ListCreated + assertThat(outcome.list.id).isEqualTo("lst_x9f2") + assertThat(outcome.list.title).isEqualTo("Launch notes") + } + + @Test + fun `To Doc posts target doc with the doc config only`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "document": { "id": "doc_y3a8", "title": "Launch notes" } }""")) + + val result = subscriberRepository().materialize( + MaterializeRequest.ToDocument( + source = MaterializeSource.Lists(listOf("lst_1")), + docConfig = DocConfig( + title = "Launch notes", + relativePath = "launch-notes.md", + isPublic = true, + listStyle = DocumentListStyle.NUMBERED, + rowDataStyle = RowDataStyle.SUB_ITEMS, + ), + ), + ) + + val body = server.takeRequest().jsonBody() + assertThat(body["target"]?.jsonPrimitive?.content).isEqualTo("doc") + assertThat(body["listConfig"]).isNull() + assertThat(body["messageConfig"]).isNull() + + val docConfig = body["docConfig"]!!.jsonObject + assertThat(docConfig["title"]?.jsonPrimitive?.content).isEqualTo("Launch notes") + assertThat(docConfig["relativePath"]?.jsonPrimitive?.content).isEqualTo("launch-notes.md") + assertThat(docConfig["isPublic"]?.jsonPrimitive?.content).isEqualTo("true") + assertThat(docConfig["listStyle"]?.jsonPrimitive?.content).isEqualTo("numbered") + // The documented spelling is hyphenated, not camelCase. + assertThat(docConfig["rowDataStyle"]?.jsonPrimitive?.content).isEqualTo("sub-items") + + val outcome = (result as ApiResult.Success).data as MaterializeOutcome.DocumentCreated + assertThat(outcome.document.id).isEqualTo("doc_y3a8") + } + + @Test + fun `To List and Doc posts target both and carries both configs`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 201, + """ + { "list": { "id": "lst_x9f2", "title": "Launch notes" }, + "document": { "id": "doc_y3a8", "title": "Launch notes" } } + """.trimIndent(), + ), + ) + + val result = subscriberRepository().materialize( + MaterializeRequest.ToListAndDocument( + source = MaterializeSource.Document("doc_src"), + listConfig = ListConfig(title = "Launch notes"), + docConfig = DocConfig(title = "Launch notes", listStyle = DocumentListStyle.BULLETED), + ), + ) + + val body = server.takeRequest().jsonBody() + assertThat(body["target"]?.jsonPrimitive?.content).isEqualTo("both") + assertThat(body["listConfig"]!!.jsonObject["title"]?.jsonPrimitive?.content) + .isEqualTo("Launch notes") + assertThat(body["docConfig"]!!.jsonObject["listStyle"]?.jsonPrimitive?.content) + .isEqualTo("bulleted") + assertThat(body["messageConfig"]).isNull() + + val outcome = (result as ApiResult.Success).data as MaterializeOutcome.ListAndDocumentCreated + assertThat(outcome.list.id).isEqualTo("lst_x9f2") + assertThat(outcome.document.id).isEqualTo("doc_y3a8") + } + + @Test + fun `To Message posts target message and returns a draft that created nothing`() = + runTest(dispatcher) { + server.enqueue( + jsonResponse( + 201, + """ + { "message": { "content": "Books to Read\n\n340 items in total.", + "thread": ["Books to Read\n\n340 items in total."], + "isThread": false, "charLimit": 300 } } + """.trimIndent(), + ), + ) + + val result = subscriberRepository().materialize( + MaterializeRequest.ToMessageDraft( + source = MaterializeSource.Lists(listOf("lst_1")), + messageConfig = MessageDraftConfig( + content = "Books to Read", + crossPostTargets = listOf(CrossPostChannel.BLUESKY, CrossPostChannel.MASTODON), + allowThread = true, + publiclyVisible = true, + tags = listOf("reading"), + scheduledAt = "2026-09-20T10:00:00.000Z", + ), + ), + ) + + val body = server.takeRequest().jsonBody() + assertThat(body["target"]?.jsonPrimitive?.content).isEqualTo("message") + assertThat(body["listConfig"]).isNull() + assertThat(body["docConfig"]).isNull() + + val messageConfig = body["messageConfig"]!!.jsonObject + assertThat(messageConfig["content"]?.jsonPrimitive?.content).isEqualTo("Books to Read") + assertThat(messageConfig["allowThread"]?.jsonPrimitive?.content).isEqualTo("true") + assertThat(messageConfig["publiclyVisible"]?.jsonPrimitive?.content).isEqualTo("true") + assertThat(messageConfig["scheduledAt"]?.jsonPrimitive?.content) + .isEqualTo("2026-09-20T10:00:00.000Z") + assertThat(messageConfig["crossPostTargets"]!!.jsonArray.map { it.jsonPrimitive.content }) + .containsExactly("bluesky", "mastodon").inOrder() + assertThat(messageConfig["tags"]!!.jsonArray.map { it.jsonPrimitive.content }) + .containsExactly("reading") + + val outcome = (result as ApiResult.Success).data as MaterializeOutcome.DraftReady + assertThat(outcome.draft.charLimit).isEqualTo(300) + assertThat(outcome.draft.isThread).isFalse() + assertThat(outcome.draft.thread).hasSize(1) + } + + // ----------------------------------------------------------- source kinds + + @Test + fun `every source kind serialises its own id fields and nothing else`() = runTest(dispatcher) { + val cases = listOf( + MaterializeSource.Messages(listOf("msg_1", "msg_2")) to + mapOf("kind" to "messages", "messageIds" to listOf("msg_1", "msg_2")), + MaterializeSource.Lists(listOf("lst_1")) to + mapOf("kind" to "lists", "listIds" to listOf("lst_1")), + MaterializeSource.Rows("lst_1", listOf("row_1", "row_2")) to + mapOf("kind" to "rows", "listId" to "lst_1", "rowIds" to listOf("row_1", "row_2")), + MaterializeSource.Document("doc_1") to + mapOf("kind" to "document", "documentId" to "doc_1"), + MaterializeSource.DocumentSelection("doc_1", "## Heading\n- a\n- b") to + mapOf( + "kind" to "docElements", + "documentId" to "doc_1", + "markdown" to "## Heading\n- a\n- b", + ), + ) + + cases.forEach { (source, expected) -> + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_new", "title": "t" } }""")) + + subscriberRepository().materialize( + MaterializeRequest.ToList(source, ListConfig(title = "t")), + ) + + val sent = server.takeRequest().jsonBody()["source"]!!.jsonObject + // Only the keys this kind defines are present — no empty siblings. + assertThat(sent.keys).containsExactlyElementsIn(expected.keys) + expected.forEach { (key, value) -> + when (value) { + is List<*> -> assertThat(sent[key]!!.jsonArray.map { it.jsonPrimitive.content }) + .isEqualTo(value) + else -> assertThat(sent[key]?.jsonPrimitive?.content).isEqualTo(value) + } + } + } + } + + @Test + fun `a rows source sends ids only - no cell values can be smuggled through`() = + runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_new", "title": "t" } }""")) + + subscriberRepository().materialize( + MaterializeRequest.ToList( + source = MaterializeSource.Rows("lst_1", listOf("row_1")), + listConfig = ListConfig( + title = "t", + fields = listOf( + MaterializeColumn( + propertyKey = "title", + propertyName = "Title", + propertyType = ListColumnType.TEXT, + sourceKey = "title", + ), + ), + ), + ), + ) + + val body = server.takeRequest().jsonBody() + // The server re-derives every value from `sourceKey`; the body must + // not contain a `rowData`/`values`/`content` payload of its own. + assertThat(body.keys).containsExactly("target", "source", "listConfig") + val field = body["listConfig"]!!.jsonObject["fields"]!!.jsonArray.single().jsonObject + assertThat(field.keys).containsExactly( + "propertyKey", + "propertyName", + "propertyType", + "sourceKey", + ) + } + + @Test + fun `a user-added column sends sourceKey as an explicit null`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_new", "title": "t" } }""")) + + subscriberRepository().materialize( + MaterializeRequest.ToList( + source = messages, + listConfig = ListConfig( + title = "t", + fields = listOf( + MaterializeColumn( + propertyKey = "notes", + propertyName = "Notes", + propertyType = ListColumnType.SELECT, + sourceKey = null, + isRequired = false, + options = listOf("todo", "done"), + ), + ), + ), + ), + ) + + val field: JsonObject = server.takeRequest().jsonBody()["listConfig"]!! + .jsonObject["fields"]!!.jsonArray.single().jsonObject + // Present and null — an omitted key would not say "user-added column". + assertThat(field).containsKey("sourceKey") + assertThat(field["sourceKey"]).isEqualTo(JsonNull) + assertThat(field["isRequired"]?.jsonPrimitive?.content).isEqualTo("false") + assertThat(field["options"]!!.jsonArray.map { it.jsonPrimitive.content }) + .containsExactly("todo", "done").inOrder() + } + + @Test + fun `every column type sends its documented wire value`() = runTest(dispatcher) { + ListColumnType.entries.forEach { type -> + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_new", "title": "t" } }""")) + + subscriberRepository().materialize( + MaterializeRequest.ToList( + source = messages, + listConfig = ListConfig( + title = "t", + fields = listOf( + MaterializeColumn("k", "K", type, sourceKey = "k"), + ), + ), + ), + ) + + val field = server.takeRequest().jsonBody()["listConfig"]!! + .jsonObject["fields"]!!.jsonArray.single().jsonObject + assertThat(field["propertyType"]?.jsonPrimitive?.content).isEqualTo(type.apiValue) + } + } + + @Test + fun `an omitted config is left out of the body entirely`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "document": { "id": "doc_1", "title": "t" } }""")) + + subscriberRepository().materialize( + MaterializeRequest.ToDocument(source = MaterializeSource.Document("doc_src")), + ) + + val body = server.takeRequest().jsonBody() + assertThat(body.keys).containsExactly("target", "source") + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeSubscriberGateTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeSubscriberGateTest.kt new file mode 100644 index 0000000..e454242 --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeSubscriberGateTest.kt @@ -0,0 +1,162 @@ +package com.interlinedlist.android.core.materialize.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.domain.ListConfig +import com.interlinedlist.android.core.materialize.domain.MaterializeAccess +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.model.CustomerStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The subscriber gate. The "Create from…" menu opens for everyone; confirming + * is what is gated, and a free account must reach the upsell **without** a write + * ever leaving the device. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaterializeSubscriberGateTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private val toList = MaterializeRequest.ToList( + source = MaterializeSource.Messages(listOf("msg_1")), + listConfig = ListConfig(title = "Launch notes"), + ) + + @Test + fun `a free account confirming a creation gets the upsell and issues no request at all`() = + runTest(dispatcher) { + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.FREE) } + + val result = repositoryFor(server, dispatcher, gate).materialize(toList) + + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + // Nothing was sent: not the write, not even a status read. + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `a free account is blocked for every creating target`() = runTest(dispatcher) { + val source = MaterializeSource.Messages(listOf("msg_1")) + val creating = listOf( + MaterializeRequest.ToList(source, ListConfig(title = "t")), + MaterializeRequest.ToDocument(source), + MaterializeRequest.ToListAndDocument(source, ListConfig(title = "t")), + ) + + creating.forEach { request -> + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.FREE) } + val result = repositoryFor(server, dispatcher, gate).materialize(request) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + } + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `the gate resolves from api user and still blocks the write`() = runTest(dispatcher) { + server.enqueue(userResponse("free")) + val gate = gateFor(server, dispatcher) + + val result = repositoryFor(server, dispatcher, gate).materialize(toList) + + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + assertThat(gate.access.value).isEqualTo(MaterializeAccess.FREE) + // The only request made was the status read; the write never happened. + assertThat(server.requestCount).isEqualTo(1) + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `a subscriber confirming a creation reaches the endpoint`() = runTest(dispatcher) { + server.enqueue(userResponse("subscriber")) + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_1", "title": "Launch notes" } }""")) + val gate = gateFor(server, dispatcher) + + val result = repositoryFor(server, dispatcher, gate).materialize(toList) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(gate.access.value).isEqualTo(MaterializeAccess.SUBSCRIBER) + assertThat(server.requestCount).isEqualTo(2) + assertThat(server.takeRequest().path).isEqualTo("/api/user") + assertThat(server.takeRequest().path).isEqualTo("/api/materialize") + } + + @Test + fun `an unreadable status does not block - the server stays the real gate`() = + runTest(dispatcher) { + // `/api/user` fails, so the client cannot prove the account is free. + server.enqueue(jsonResponse(500, """{ "error": "Server error", "code": "internal_error" }""")) + server.enqueue(jsonResponse(201, """{ "list": { "id": "lst_1", "title": "Launch notes" } }""")) + val gate = gateFor(server, dispatcher) + + val result = repositoryFor(server, dispatcher, gate).materialize(toList) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(gate.access.value).isEqualTo(MaterializeAccess.UNKNOWN) + } + + @Test + fun `the message target is never blocked client-side because it creates nothing`() = + runTest(dispatcher) { + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.FREE) } + server.enqueue( + jsonResponse( + 201, + """{ "message": { "content": "Books", "thread": ["Books"], + "isThread": false, "charLimit": 300 } }""", + ), + ) + + val result = repositoryFor(server, dispatcher, gate).materialize( + MaterializeRequest.ToMessageDraft(MaterializeSource.Lists(listOf("lst_1"))), + ) + + // To Message writes nothing, and the help centre documents posting + // itself as free, so the client does not pre-empt it. If the server + // refuses, the 403 still becomes the upsell (see the next test). + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.takeRequest().path).isEqualTo("/api/materialize") + } + + @Test + fun `a server subscription refusal is folded back into the gate`() = runTest(dispatcher) { + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.SUBSCRIBER) } + server.enqueue( + jsonResponse(403, """{ "error": "Subscriber feature", "code": "subscription_required" }"""), + ) + val repository = repositoryFor(server, dispatcher, gate) + + val first = repository.materialize(toList) + assertThat((first as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + assertThat(gate.access.value).isEqualTo(MaterializeAccess.FREE) + + // The next confirm short-circuits instead of issuing another refused write. + val second = repository.materialize(toList) + assertThat((second as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + assertThat(server.requestCount).isEqualTo(1) + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeTestFixtures.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeTestFixtures.kt new file mode 100644 index 0000000..3f2cb17 --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/data/MaterializeTestFixtures.kt @@ -0,0 +1,75 @@ +package com.interlinedlist.android.core.materialize.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.materialize.data.remote.MaterializeApi +import com.interlinedlist.android.core.materialize.domain.MaterializeGate +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import retrofit2.Retrofit + +/** The same Json configuration `NetworkModule` installs in the app. */ +internal fun testJson(): Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true +} + +internal fun testDispatchers(dispatcher: CoroutineDispatcher): DispatcherProvider = + object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + +internal fun retrofitFor(server: MockWebServer, json: Json): Retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + +/** A gate over the same MockWebServer, exactly as Hilt wires it (one Retrofit). */ +internal fun gateFor( + server: MockWebServer, + dispatcher: CoroutineDispatcher, + json: Json = testJson(), +): MaterializeGate = MaterializeGate( + userApi = retrofitFor(server, json).create(InterlinedListApi::class.java), + json = json, + dispatchers = testDispatchers(dispatcher), +) + +/** + * Builds the repository over a real Retrofit/OkHttp stack pointed at [server]. + * The request bodies these tests assert on are the ones the app would send. + */ +internal fun repositoryFor( + server: MockWebServer, + dispatcher: CoroutineDispatcher, + gate: MaterializeGate = gateFor(server, dispatcher), + json: Json = testJson(), +): DefaultMaterializeRepository = DefaultMaterializeRepository( + api = retrofitFor(server, json).create(MaterializeApi::class.java), + gate = gate, + json = json, + dispatchers = testDispatchers(dispatcher), +) + +internal fun jsonResponse(code: Int, body: String): MockResponse = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) + +/** `GET /api/user` as the gate reads it. */ +internal fun userResponse(customerStatus: String): MockResponse = jsonResponse( + 200, + """{ "user": { "id": "usr_1", "username": "adron", "customerStatus": "$customerStatus" } }""", +) + +internal fun RecordedRequest.jsonBody(): JsonObject = + Json.decodeFromString(JsonObject.serializer(), body.readUtf8()) diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeDomainTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeDomainTest.kt new file mode 100644 index 0000000..e1c5ffd --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeDomainTest.kt @@ -0,0 +1,87 @@ +package com.interlinedlist.android.core.materialize.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The domain type is the contract: the four destinations, the five source kinds, + * and the combinations the API refuses. + */ +class MaterializeDomainTest { + + @Test + fun `each destination carries its own target`() { + val source = MaterializeSource.Document("doc_1") + + assertThat(MaterializeRequest.ToList(source, ListConfig("t")).target) + .isEqualTo(MaterializeTarget.LIST) + assertThat(MaterializeRequest.ToDocument(source).target) + .isEqualTo(MaterializeTarget.DOC) + assertThat(MaterializeRequest.ToListAndDocument(source, ListConfig("t")).target) + .isEqualTo(MaterializeTarget.BOTH) + assertThat(MaterializeRequest.ToMessageDraft(source).target) + .isEqualTo(MaterializeTarget.MESSAGE) + } + + @Test + fun `only the message target creates nothing`() { + assertThat(MaterializeTarget.entries.filterNot { it.createsContent }) + .containsExactly(MaterializeTarget.MESSAGE) + } + + @Test + fun `targets use the documented wire values`() { + assertThat(MaterializeTarget.entries.map { it.apiValue }) + .containsExactly("list", "doc", "both", "message").inOrder() + } + + @Test + fun `source kinds use the documented discriminators`() { + assertThat(MaterializeSource.Messages(listOf("m")).kind).isEqualTo("messages") + assertThat(MaterializeSource.Lists(listOf("l")).kind).isEqualTo("lists") + assertThat(MaterializeSource.Rows("l", listOf("r")).kind).isEqualTo("rows") + assertThat(MaterializeSource.Document("d").kind).isEqualTo("document") + assertThat(MaterializeSource.DocumentSelection("d", "# h").kind).isEqualTo("docElements") + } + + @Test + fun `an empty selection cannot be materialized`() { + listOf<() -> Any>( + { MaterializeSource.Messages(emptyList()) }, + { MaterializeSource.Lists(emptyList()) }, + { MaterializeSource.Rows("lst_1", emptyList()) }, + { MaterializeSource.Rows("", listOf("row_1")) }, + { MaterializeSource.Document(" ") }, + { MaterializeSource.DocumentSelection("doc_1", " ") }, + ).forEach { build -> + runCatching { build() }.also { + assertThat(it.exceptionOrNull()).isInstanceOf(IllegalArgumentException::class.java) + } + } + } + + @Test + fun `a list cannot be created without a title - the server requires one`() { + val thrown = runCatching { ListConfig(title = " ") }.exceptionOrNull() + assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `column types cover the twelve the schema accepts`() { + assertThat(ListColumnType.entries.map { it.apiValue }).containsExactly( + "text", "textarea", "number", "boolean", "date", "datetime", + "email", "url", "tel", "select", "multiselect", "priority", + ) + assertThat(ListColumnType.fromApiValue("multiselect")) + .isEqualTo(ListColumnType.MULTISELECT) + assertThat(ListColumnType.fromApiValue("integer")).isNull() + } + + @Test + fun `document styles use the documented spellings`() { + assertThat(DocumentListStyle.entries.map { it.apiValue }) + .containsExactly("numbered", "bulleted") + assertThat(RowDataStyle.entries.map { it.apiValue }) + .containsExactly("inline", "sub-items") + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGateTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGateTest.kt new file mode 100644 index 0000000..590e809 --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/domain/MaterializeGateTest.kt @@ -0,0 +1,106 @@ +package com.interlinedlist.android.core.materialize.domain + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.materialize.data.gateFor +import com.interlinedlist.android.core.materialize.data.jsonResponse +import com.interlinedlist.android.core.materialize.data.userResponse +import com.interlinedlist.android.core.model.CustomerStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** How the gate resolves `customerStatus` and what it does when it cannot. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaterializeGateTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `it starts unknown so the menu is never hidden`() = runTest(dispatcher) { + assertThat(gateFor(server, dispatcher).access.value).isEqualTo(MaterializeAccess.UNKNOWN) + assertThat(MaterializeAccess.UNKNOWN.isKnownFree).isFalse() + } + + @Test + fun `every subscriber tier resolves to SUBSCRIBER`() = runTest(dispatcher) { + listOf("subscriber", "subscriber:monthly", "subscriber:annual").forEach { tier -> + server.enqueue(userResponse(tier)) + assertThat(gateFor(server, dispatcher).refresh()) + .isEqualTo(MaterializeAccess.SUBSCRIBER) + } + } + + @Test + fun `free resolves to FREE`() = runTest(dispatcher) { + server.enqueue(userResponse("free")) + val gate = gateFor(server, dispatcher) + assertThat(gate.refresh()).isEqualTo(MaterializeAccess.FREE) + assertThat(gate.access.value.isKnownFree).isTrue() + } + + @Test + fun `an unrecognised tier is not treated as evidence of a free account`() = runTest(dispatcher) { + server.enqueue(userResponse("enterprise-something")) + assertThat(gateFor(server, dispatcher).refresh()).isEqualTo(MaterializeAccess.UNKNOWN) + } + + @Test + fun `an unreadable status stays unknown rather than locking a subscriber out`() = + runTest(dispatcher) { + server.enqueue(jsonResponse(401, """{ "error": "Unauthorized" }""")) + assertThat(gateFor(server, dispatcher).refresh()).isEqualTo(MaterializeAccess.UNKNOWN) + } + + @Test + fun `ensureResolved reads once and then stops calling api user`() = runTest(dispatcher) { + server.enqueue(userResponse("subscriber")) + val gate = gateFor(server, dispatcher) + + assertThat(gate.ensureResolved()).isEqualTo(MaterializeAccess.SUBSCRIBER) + assertThat(gate.ensureResolved()).isEqualTo(MaterializeAccess.SUBSCRIBER) + + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `ensureResolved retries while the status is still unknown`() = runTest(dispatcher) { + server.enqueue(jsonResponse(500, """{ "error": "Server error" }""")) + server.enqueue(userResponse("subscriber")) + val gate = gateFor(server, dispatcher) + + assertThat(gate.ensureResolved()).isEqualTo(MaterializeAccess.UNKNOWN) + assertThat(gate.ensureResolved()).isEqualTo(MaterializeAccess.SUBSCRIBER) + } + + @Test + fun `a recorded status spares the network read`() = runTest(dispatcher) { + val gate = gateFor(server, dispatcher) + gate.record(CustomerStatus.SUBSCRIBER_ANNUAL) + + assertThat(gate.ensureResolved()).isEqualTo(MaterializeAccess.SUBSCRIBER) + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `a refusal from the server is remembered`() = runTest(dispatcher) { + val gate = gateFor(server, dispatcher) + gate.record(CustomerStatus.SUBSCRIBER) + + gate.recordSubscriptionRequired() + + assertThat(gate.access.value).isEqualTo(MaterializeAccess.FREE) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f2c9550..d679973 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,10 @@ include(":core:designsystem") include(":core:network") include(":core:database") include(":core:datastore") +// Shared cross-feature capability: "Create from…" (POST /api/materialize) is +// invoked from :feature:messages, :feature:lists and :feature:documents, so it +// cannot live inside any one of them. +include(":core:materialize") // Feature modules (added per roadmap phase) include(":feature:auth")