From 89144f75f4ff73ecd6fd7005169ae6c9a579b943 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Mon, 19 Oct 2026 17:11:13 +0200 Subject: [PATCH 1/5] Add init e2e test setup --- CONTRIBUTING.md | 5 + app/build.gradle.kts | 28 ++++ .../kotlin/LemmyV1CommentsE2eTest.kt | 19 +++ app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt | 139 ++++++++++++++++++ .../kotlin/LemmyV1PersonsE2eTest.kt | 18 +++ .../jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt | 19 +++ .../resources/lemmy-e2e-config.hjson | 17 +++ app/src/jvmE2eTest/resources/logback-test.xml | 12 ++ 8 files changed, 257 insertions(+) create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt create mode 100644 app/src/jvmE2eTest/resources/lemmy-e2e-config.hjson create mode 100644 app/src/jvmE2eTest/resources/logback-test.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8303de735..c9b8f7a42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,11 @@ The `it.vercruysse.lemmyapi.datatypes` package represents the **1.0.0 Lemmy API* - **Enum entries**: each entry tracks `minimumVersion`/`maximumVersion`. Use `getSupportedEntries()` to retrieve the valid entries for the connected instance (e.g. sort options). +## End-to-end tests + +`gradlew :app:e2eTest` runs the library against a real Lemmy server (currently v1 only) started with Testcontainers, so a running Docker engine is required. + + ## Compatibility Rules When adding or updating an older-version implementation: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 862595b47..2085c9410 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,6 +25,12 @@ kotlin { useJUnitPlatform() } } + + // Separate compilation so the Docker-based E2E tests never run as part of `jvmTest`/`check` + val mainCompilation = compilations.getByName("main") + compilations.create("e2eTest") { + associateWith(mainCompilation) + } } android { @@ -92,6 +98,13 @@ kotlin { implementation("io.ktor:ktor-client-mock:$ktorVersion") } + getByName("jvmE2eTest").dependencies { + implementation(kotlin("test-junit5")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + implementation("org.testcontainers:testcontainers:1.21.4") + implementation("ch.qos.logback:logback-classic:1.6.3") + } + // Below required bc we use HttpClient() see https://ktor.io/docs/client-engines.html#default jsMain.dependencies { @@ -129,6 +142,21 @@ kotlin { } +val e2eCompilation = kotlin.jvm().compilations.getByName("e2eTest") + +tasks.register("e2eTest") { + description = "Runs JVM E2E tests against a local Lemmy docker setup." + group = "verification" + testClassesDirs = e2eCompilation.output.classesDirs + classpath = files(e2eCompilation.output.allOutputs, e2eCompilation.runtimeDependencyFiles) + useJUnitPlatform() + outputs.upToDateWhen { false } + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = true + } +} + tasks.withType { exclude("**/datatypes/**") } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt new file mode 100644 index 000000000..2f6fd8f52 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt @@ -0,0 +1,19 @@ +import it.vercruysse.lemmyapi.datatypes.GetComments +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1CommentsE2eTest : LemmyV1E2eTest() { + @Test + fun `anonymous comment list returns the seeded comment`() = + runBlocking { + val response = api.getComments(GetComments(post_id = postId)).getOrThrow() + val comment = response.items.single { it.comment.content == SEED_COMMENT_CONTENT } + + assertEquals(postId, comment.post.id) + assertEquals(SEED_POST_TITLE, comment.post.name) + assertEquals(ADMIN_USERNAME, comment.creator.name) + assertTrue(comment.comment.published_at.isNotBlank()) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt new file mode 100644 index 000000000..3b9d4762c --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt @@ -0,0 +1,139 @@ +import it.vercruysse.lemmyapi.LemmyApiBaseController +import it.vercruysse.lemmyapi.LemmyApiClient +import it.vercruysse.lemmyapi.LemmyApiOptions +import it.vercruysse.lemmyapi.LemmyAuth +import it.vercruysse.lemmyapi.LemmyInstance +import it.vercruysse.lemmyapi.LemmyVersion +import it.vercruysse.lemmyapi.datatypes.CreateComment +import it.vercruysse.lemmyapi.datatypes.CreateCommunity +import it.vercruysse.lemmyapi.datatypes.CreatePost +import it.vercruysse.lemmyapi.datatypes.Login +import kotlinx.coroutines.runBlocking +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.Network +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import org.testcontainers.utility.MountableFile +import java.time.Duration +import kotlin.time.Duration.Companion.seconds + +abstract class LemmyV1E2eTest { + protected val api: LemmyApiBaseController get() = env.api + protected val postId: Long get() = env.postId + + private class Environment { + private val network = Network.newNetwork() + private val postgres = + LemmyContainer("postgres:16.4-alpine") + .withNetwork(network) + .withNetworkAliases("postgres") + .withEnv("POSTGRES_USER", "lemmy") + .withEnv("POSTGRES_PASSWORD", "password") + .withEnv("POSTGRES_DB", "lemmy") + .withExposedPorts(POSTGRES_PORT) + .waitingFor(Wait.forListeningPort()) + private val pictrs = + LemmyContainer("asonix/pictrs:0.5.16") + .withNetwork(network) + .withNetworkAliases("pictrs") + .withEnv("PICTRS__SERVER__API_KEY", "lemmy-e2e-pictrs-key") + .withExposedPorts(PICTRS_PORT) + .waitingFor(Wait.forListeningPort()) + private val lemmy = + LemmyContainer("dessalines/lemmy:$LEMMY_VERSION") + .withNetwork(network) + .withEnv("LEMMY_DISABLE_ACTIVITY_SENDING", "true") + .withEnv("RUST_LOG", "warn") + .withCopyFileToContainer( + MountableFile.forClasspathResource("lemmy-e2e-config.hjson"), + "/config/config.hjson", + ) + .withExposedPorts(LEMMY_PORT) + .waitingFor( + Wait.forHttp("/api/v4/site") + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(4)), + ) + + private val client = + LemmyApiClient( + options = + LemmyApiOptions( + requestTimeout = 30.seconds, + maxRetries = 5, + userAgent = "LemmyBackwardsCompatibleAPI-E2E", + ), + ) + + lateinit var api: LemmyApiBaseController + private set + var postId = 0L + private set + + fun start() { + try { + postgres.start() + pictrs.start() + lemmy.start() + seed() + } catch (t: Throwable) { + stop() + throw t + } + } + + private fun seed() = + runBlocking { + val instance = LemmyInstance("http://${lemmy.host}:${lemmy.getMappedPort(LEMMY_PORT)}") + val version = LemmyVersion(LEMMY_VERSION) + + val admin = client.connectForVersion(instance, version).getOrThrow() + val jwt = admin.login(Login(ADMIN_USERNAME, ADMIN_PASSWORD)).getOrThrow().jwt + checkNotNull(jwt) { "Lemmy did not return a token for the admin user" } + admin.updateAuth(LemmyAuth.Bearer(jwt)) + + val community = + admin.createCommunity(CreateCommunity(name = SEED_COMMUNITY_NAME, title = "E2E Test Community")) + .getOrThrow().community_view.community + val post = + admin.createPost( + CreatePost(name = SEED_POST_TITLE, body = SEED_POST_BODY, community_id = community.id), + ).getOrThrow().post_view.post + admin.createComment(CreateComment(content = SEED_COMMENT_CONTENT, post_id = post.id)).getOrThrow() + + postId = post.id + api = client.connectForVersion(instance, version).getOrThrow() + } + + fun stop() { + runCatching { client.close() } + runCatching { lemmy.stop() } + runCatching { pictrs.stop() } + runCatching { postgres.stop() } + runCatching { network.close() } + } + } + + private class LemmyContainer(image: String) : GenericContainer(DockerImageName.parse(image)) + + protected companion object { + const val LEMMY_VERSION = "1.0.0-beta.2" + const val ADMIN_USERNAME = "lemmy" + const val ADMIN_PASSWORD = "lemmylemmy" + const val SEED_COMMUNITY_NAME = "e2ebasic" + const val SEED_POST_TITLE = "E2E seeded post" + const val SEED_POST_BODY = "A post created through the public unified API." + const val SEED_COMMENT_CONTENT = "An E2E comment created through the public unified API." + + private const val LEMMY_PORT = 8536 + private const val PICTRS_PORT = 8080 + private const val POSTGRES_PORT = 5432 + + private val env: Environment by lazy { + Environment().also { + Runtime.getRuntime().addShutdownHook(Thread { it.stop() }) + it.start() + } + } + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt new file mode 100644 index 000000000..b603b0f3f --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt @@ -0,0 +1,18 @@ +import it.vercruysse.lemmyapi.datatypes.GetPersonDetails +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1PersonsE2eTest : LemmyV1E2eTest() { + @Test + fun `anonymous person details returns the seeded administrator`() = + runBlocking { + val response = api.getPersonDetails(GetPersonDetails(username = ADMIN_USERNAME)).getOrThrow() + val person = response.person_view.person + + assertEquals(ADMIN_USERNAME, person.name) + assertTrue(person.local) + assertTrue(person.published_at.isNotBlank()) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt new file mode 100644 index 000000000..d85f87db4 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt @@ -0,0 +1,19 @@ +import it.vercruysse.lemmyapi.datatypes.GetPosts +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1PostsE2eTest : LemmyV1E2eTest() { + @Test + fun `anonymous post list returns the seeded post`() = + runBlocking { + val response = api.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME)).getOrThrow() + val post = response.items.single { it.post.id == postId } + + assertEquals(SEED_POST_TITLE, post.post.name) + assertEquals(SEED_POST_BODY, post.post.body) + assertEquals(ADMIN_USERNAME, post.creator.name) + assertTrue(post.post.published_at.isNotBlank()) + } +} diff --git a/app/src/jvmE2eTest/resources/lemmy-e2e-config.hjson b/app/src/jvmE2eTest/resources/lemmy-e2e-config.hjson new file mode 100644 index 000000000..1dabed601 --- /dev/null +++ b/app/src/jvmE2eTest/resources/lemmy-e2e-config.hjson @@ -0,0 +1,17 @@ +{ + setup: { + admin_username: "lemmy" + admin_password: "lemmylemmy" + site_name: "lemmy-e2e" + } + database: { + connection: "postgres://lemmy:password@postgres:5432/lemmy" + } + hostname: "lemmy" + bind: "0.0.0.0" + port: 8536 + pictrs: { + url: "http://pictrs:8080/" + api_key: "lemmy-e2e-pictrs-key" + } +} diff --git a/app/src/jvmE2eTest/resources/logback-test.xml b/app/src/jvmE2eTest/resources/logback-test.xml new file mode 100644 index 000000000..22199e382 --- /dev/null +++ b/app/src/jvmE2eTest/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - %msg%n + + + + + + + + From 8695307a4546bbf539aaed61d8650a5c7320b8b5 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Mon, 19 Oct 2026 18:29:22 +0200 Subject: [PATCH 2/5] Add more e2e test coverage --- .../main/kotlin/autogenscripts/GenTypes.kt | 14 +- .../lemmyapi/v1/x0/x0/LemmyApiUniWrapper.kt | 48 ++--- .../v1/x0/x0/datatypes/NotificationData.kt | 2 + .../x0/datatypes/PostCommentCombinedView.kt | 2 + .../PostOrCommentOrPrivateMessage.kt | 2 + .../v1/x0/x0/datatypes/ReportCombinedView.kt | 2 + .../v1/x0/x0/datatypes/ResolveObjectView.kt | 2 + .../kotlin/LemmyV1AccountE2eTest.kt | 85 ++++++++ .../kotlin/LemmyV1CommentsE2eTest.kt | 119 ++++++++++- .../kotlin/LemmyV1CommunitiesE2eTest.kt | 80 +++++++ .../kotlin/LemmyV1E2eEnvironment.kt | 201 ++++++++++++++++++ app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt | 183 ++++++---------- .../kotlin/LemmyV1NotificationsE2eTest.kt | 79 +++++++ .../kotlin/LemmyV1PersonsE2eTest.kt | 71 ++++++- .../jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt | 167 ++++++++++++++- .../jvmE2eTest/kotlin/LemmyV1SearchE2eTest.kt | 61 ++++++ .../jvmE2eTest/kotlin/LemmyV1SiteE2eTest.kt | 29 +++ app/src/jvmE2eTest/kotlin/Utils.kt | 22 ++ 18 files changed, 1022 insertions(+), 147 deletions(-) create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1AccountE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1CommunitiesE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1NotificationsE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1SearchE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1SiteE2eTest.kt create mode 100644 app/src/jvmE2eTest/kotlin/Utils.kt diff --git a/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt b/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt index f88d77964..18bfb4fbc 100644 --- a/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt +++ b/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt @@ -78,13 +78,15 @@ val fileFieldEnumOverrides = mapOf( // Maps the interface name to its @JsonClassDiscriminator field, and whether it needs @Serializable. data class SealedInterface(val discriminator: String, val serializable: Boolean = false) -// TODO: don't know yet if serializable required but also it breaks the generated code +// Interfaces that are deserialized polymorphically (as response fields) must be @Serializable, +// otherwise the class discriminator is not resolved at runtime. +// TODO refactor val sealedInterfaces = mapOf( - "PostOrCommentOrPrivateMessage" to SealedInterface("type_"), - "ResolveObjectView" to SealedInterface("type_"), - "ReportCombinedView" to SealedInterface("type_"), - "NotificationData" to SealedInterface("type_", serializable = false), - "PostCommentCombinedView" to SealedInterface("type_"), + "PostOrCommentOrPrivateMessage" to SealedInterface("type_", serializable = true), + "ResolveObjectView" to SealedInterface("type_", serializable = true), + "ReportCombinedView" to SealedInterface("type_", serializable = true), + "NotificationData" to SealedInterface("type_", serializable = true), + "PostCommentCombinedView" to SealedInterface("type_", serializable = true), ) // Which data classes implement which sealed interfaces and what @SerialName they carry. diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/LemmyApiUniWrapper.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/LemmyApiUniWrapper.kt index 6c2ba07e6..8d682e4f0 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/LemmyApiUniWrapper.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/LemmyApiUniWrapper.kt @@ -32,7 +32,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun getModlog(form: it.vercruysse.lemmyapi.datatypes.GetModlog): Result> = api.getModlog(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun search(form: it.vercruysse.lemmyapi.datatypes.Search): Result = @@ -55,7 +55,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listCommunities(form: it.vercruysse.lemmyapi.datatypes.ListCommunities): Result> = api.listCommunities(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun followCommunity(form: it.vercruysse.lemmyapi.datatypes.FollowCommunity): Result = @@ -81,7 +81,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun getFederatedInstances(form: it.vercruysse.lemmyapi.datatypes.GetFederatedInstances): Result> = api.getFederatedInstances(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun getPost(form: it.vercruysse.lemmyapi.datatypes.GetPost): Result = @@ -95,7 +95,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun getPosts(form: it.vercruysse.lemmyapi.datatypes.GetPosts): Result> = api.getPosts(transformer.fromUni(form.toValidatedForm())).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun deletePost(form: it.vercruysse.lemmyapi.datatypes.DeletePost): Result = @@ -130,7 +130,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listReports(form: it.vercruysse.lemmyapi.datatypes.ListReports): Result> = api.listReports(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun getSiteMetadata(form: it.vercruysse.lemmyapi.datatypes.GetSiteMetadata): Result = @@ -147,7 +147,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun getComments(form: it.vercruysse.lemmyapi.datatypes.GetComments): Result> = api.getComments(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun deleteComment(form: it.vercruysse.lemmyapi.datatypes.DeleteComment): Result = @@ -191,7 +191,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listPersons(form: it.vercruysse.lemmyapi.datatypes.ListPersons): Result> = api.listPersons(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun register(form: it.vercruysse.lemmyapi.datatypes.Register): Result = @@ -208,7 +208,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listNotifications(form: ListNotifications): Result> = api.listNotifications(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun banPerson(form: it.vercruysse.lemmyapi.datatypes.BanPerson): Result = @@ -255,7 +255,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listRegistrationApplications(form: it.vercruysse.lemmyapi.datatypes.ListRegistrationApplications): Result> = api.listRegistrationApplications(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun approveRegistrationApplication(form: it.vercruysse.lemmyapi.datatypes.ApproveRegistrationApplication): Result = @@ -314,12 +314,12 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listPostLikes(form: it.vercruysse.lemmyapi.datatypes.ListPostLikes): Result> = api.listPostLikes(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listCommentLikes(form: it.vercruysse.lemmyapi.datatypes.ListCommentLikes): Result> = api.listCommentLikes(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun createRegistrationInvitation(form: it.vercruysse.lemmyapi.datatypes.CreateInvitation): Result = @@ -330,17 +330,17 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listRegistrationInvitations(form: it.vercruysse.lemmyapi.datatypes.ListInvitations): Result> = api.listRegistrationInvitations(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listMedia(form: it.vercruysse.lemmyapi.datatypes.ListMedia): Result> = api.listMedia(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listMediaAdmin(form: it.vercruysse.lemmyapi.datatypes.ListMedia): Result> = api.listMediaAdmin(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun hidePost(form: it.vercruysse.lemmyapi.datatypes.HidePost): Result = @@ -353,7 +353,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listPersonContent(form: it.vercruysse.lemmyapi.datatypes.ListPersonContent): Result> = api.listPersonContent(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun uploadImage(image: ByteArray): Result = @@ -406,7 +406,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun adminListUsers(form: it.vercruysse.lemmyapi.datatypes.AdminListUsers): Result> = api.adminListUsers(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun getRandomCommunity(form: it.vercruysse.lemmyapi.datatypes.GetRandomCommunity): Result = @@ -435,7 +435,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listCommunityPendingFollows(form: it.vercruysse.lemmyapi.datatypes.ListCommunityPendingFollows): Result> = api.listCommunityPendingFollows(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun modEditPost(form: it.vercruysse.lemmyapi.datatypes.ModEditPost): Result = @@ -449,7 +449,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun getCommentsSlim(form: it.vercruysse.lemmyapi.datatypes.GetComments): Result> = api.getCommentsSlim(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun lockComment(form: it.vercruysse.lemmyapi.datatypes.LockComment): Result = @@ -463,22 +463,22 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listPersonSaved(form: it.vercruysse.lemmyapi.datatypes.ListPersonSaved): Result> = api.listPersonSaved(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listPersonRead(form: it.vercruysse.lemmyapi.datatypes.ListPersonRead): Result> = api.listPersonRead(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listPersonHidden(form: it.vercruysse.lemmyapi.datatypes.ListPersonHidden): Result> = api.listPersonHidden(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun listPersonLiked(form: it.vercruysse.lemmyapi.datatypes.ListPersonLiked): Result> = api.listPersonLiked(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun resendVerificationEmail(form: it.vercruysse.lemmyapi.datatypes.ResendVerificationEmail): Result = @@ -507,7 +507,7 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listTaglines(form: it.vercruysse.lemmyapi.datatypes.ListTaglines): Result> = api.listTaglines(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } override suspend fun createOAuthProvider(form: it.vercruysse.lemmyapi.datatypes.CreateOAuthProvider): Result = @@ -539,6 +539,6 @@ internal class LemmyApiUniWrapper(client: HttpClient, apiBaseUrl: String, instan override suspend fun listMultiCommunities(form: it.vercruysse.lemmyapi.datatypes.ListMultiCommunities): Result> = api.listMultiCommunities(transformer.fromUni(form)).map { resp -> - PagedResponse(resp.items.map(transformer::toUni)) + PagedResponse(resp.items.map(transformer::toUni), resp.next_page, resp.prev_page) } } diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/NotificationData.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/NotificationData.kt index 7da614c75..719d089f1 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/NotificationData.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/NotificationData.kt @@ -1,8 +1,10 @@ package it.vercruysse.lemmyapi.v1.x0.x0.datatypes import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") internal sealed interface NotificationData diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostCommentCombinedView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostCommentCombinedView.kt index 85b714854..fb097e933 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostCommentCombinedView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostCommentCombinedView.kt @@ -1,8 +1,10 @@ package it.vercruysse.lemmyapi.v1.x0.x0.datatypes import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") internal sealed interface PostCommentCombinedView diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostOrCommentOrPrivateMessage.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostOrCommentOrPrivateMessage.kt index 186dff677..da0fa6111 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostOrCommentOrPrivateMessage.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/PostOrCommentOrPrivateMessage.kt @@ -1,8 +1,10 @@ package it.vercruysse.lemmyapi.v1.x0.x0.datatypes import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") internal sealed interface PostOrCommentOrPrivateMessage diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ReportCombinedView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ReportCombinedView.kt index b8cf05848..e7bed1aa6 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ReportCombinedView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ReportCombinedView.kt @@ -1,8 +1,10 @@ package it.vercruysse.lemmyapi.v1.x0.x0.datatypes import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") internal sealed interface ReportCombinedView diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ResolveObjectView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ResolveObjectView.kt index dbb5abebb..c0797c109 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ResolveObjectView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/v1/x0/x0/datatypes/ResolveObjectView.kt @@ -1,8 +1,10 @@ package it.vercruysse.lemmyapi.v1.x0.x0.datatypes import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") internal sealed interface ResolveObjectView diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1AccountE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1AccountE2eTest.kt new file mode 100644 index 000000000..7c9399981 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1AccountE2eTest.kt @@ -0,0 +1,85 @@ +import it.vercruysse.lemmyapi.datatypes.Login +import it.vercruysse.lemmyapi.datatypes.SaveUserSettings +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class LemmyV1AccountE2eTest : LemmyV1E2eTest() { + @Test + fun `registered user can fetch its own account`() = + runBlocking { + val registered = newUser() + + val me = registered.api.getMyUser().getOrThrow().local_user_view + + assertEquals(registered.username, me.person.name) + assertFalse(me.local_user.admin) + } + + @Test + fun `admin account is flagged as admin`() = + runBlocking { + val me = adminApi.getMyUser().getOrThrow().local_user_view + + assertEquals(ADMIN_USERNAME, me.person.name) + assertTrue(me.local_user.admin) + } + + @Test + fun `login with valid credentials returns a token`() = + runBlocking { + val response = anonApi.login(Login(user.username, USER_PASSWORD)).getOrThrow() + + assertNotNull(response.jwt) + } + + @Test + fun `login with a wrong password fails`() = + runBlocking { + val result = anonApi.login(Login(user.username, "definitely-not-the-password")) + + assertTrue(result.isFailure) + } + + @Test + fun `validate auth only succeeds when authenticated`() = + runBlocking { + assertTrue(user.api.validateAuth().isSuccess) + assertTrue(anonApi.validateAuth().isFailure) + } + + @Test + fun `saved user settings are returned with the account`() = + runBlocking { + val settingsUser = newUser() + + settingsUser.api.saveUserSettings( + SaveUserSettings(display_name = "E2E Display", bio = "E2E bio", show_nsfw = true), + ).getOrThrow() + + val me = settingsUser.api.getMyUser().getOrThrow().local_user_view + assertEquals("E2E Display", me.person.display_name) + assertEquals("E2E bio", me.person.bio) + assertTrue(me.local_user.show_nsfw) + } + + @Test + fun `logout invalidates the token`() = + runBlocking { + val loggedOut = newUser() + assertTrue(loggedOut.api.validateAuth().isSuccess) + + loggedOut.api.logout().getOrThrow() + + assertTrue(loggedOut.api.validateAuth().isFailure) + } + + @Test + fun `new user has no unread notifications`() = + runBlocking { + assertEquals(0, newUser().api.getUnreadCounts().getOrThrow().notification_count) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt index 2f6fd8f52..82cfafeb3 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1CommentsE2eTest.kt @@ -1,19 +1,136 @@ +import it.vercruysse.lemmyapi.datatypes.CreateCommentLike +import it.vercruysse.lemmyapi.datatypes.CreateCommentReport +import it.vercruysse.lemmyapi.datatypes.DeleteComment +import it.vercruysse.lemmyapi.datatypes.EditComment +import it.vercruysse.lemmyapi.datatypes.GetComment import it.vercruysse.lemmyapi.datatypes.GetComments +import it.vercruysse.lemmyapi.datatypes.SaveComment +import it.vercruysse.lemmyapi.enums.CommentSortType +import it.vercruysse.lemmyapi.enums.VoteAction import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class LemmyV1CommentsE2eTest : LemmyV1E2eTest() { @Test fun `anonymous comment list returns the seeded comment`() = runBlocking { - val response = api.getComments(GetComments(post_id = postId)).getOrThrow() + val response = anonApi.getComments(GetComments(post_id = postId)).getOrThrow() val comment = response.items.single { it.comment.content == SEED_COMMENT_CONTENT } assertEquals(postId, comment.post.id) assertEquals(SEED_POST_TITLE, comment.post.name) assertEquals(ADMIN_USERNAME, comment.creator.name) assertTrue(comment.comment.published_at.isNotBlank()) + assertNull(comment.comment_actions) + } + + @Test + fun `anonymous comment list can be paged`() = + runBlocking { + val pages = + fetchPages { cursor -> + anonApi.getComments(GetComments(post_id = postId, limit = 2, sort = CommentSortType.Old, page_cursor = cursor)) + } + val ids = pages.flatten().map { it.comment.id } + + assertTrue(pages.size >= 3, "Expected multiple pages but got ${pages.size}") + assertEquals(2, pages.first().size) + assertEquals(ids.distinct(), ids, "Pages must not repeat comments") + assertTrue(ids.containsAll(seededCommentIds)) + } + + @Test + fun `anonymous get comment returns the seeded comment`() = + runBlocking { + val response = anonApi.getComment(GetComment(id = commentId)).getOrThrow() + + assertEquals(SEED_COMMENT_CONTENT, response.comment_view.comment.content) + assertEquals(postId, response.comment_view.post.id) + } + + @Test + fun `replies are nested under their parent`() = + runBlocking { + val post = adminApi.newPost() + val parent = adminApi.newComment(onPost = post.post.id) + val reply = user.api.newComment(onPost = post.post.id, parent = parent.comment.id) + + assertEquals(user.username, reply.creator.name) + assertTrue(reply.comment.path.contains(".${parent.comment.id}."), "Unexpected path ${reply.comment.path}") + + val children = anonApi.getComments(GetComments(post_id = post.post.id, parent_id = parent.comment.id)).getOrThrow() + assertTrue(children.items.any { it.comment.id == reply.comment.id }) + } + + @Test + fun `user can create a comment`() = + runBlocking { + val post = adminApi.newPost() + + val comment = user.api.newComment(content = "Hello from the user", onPost = post.post.id) + + assertEquals("Hello from the user", comment.comment.content) + assertEquals(user.username, comment.creator.name) + val listed = anonApi.getComments(GetComments(post_id = post.post.id)).getOrThrow() + assertEquals(listOf(comment.comment.id), listed.items.map { it.comment.id }) + } + + @Test + fun `user can edit a comment`() = + runBlocking { + val comment = user.api.newComment() + + val edited = user.api.editComment(EditComment(comment.comment.id, content = "Edited content")).getOrThrow() + + assertEquals("Edited content", edited.comment_view.comment.content) + assertEquals("Edited content", anonApi.getComment(GetComment(comment.comment.id)).getOrThrow().comment_view.comment.content) + } + + @Test + fun `user can delete a comment`() = + runBlocking { + val comment = user.api.newComment() + + val deleted = user.api.deleteComment(DeleteComment(comment.comment.id, deleted = true)).getOrThrow() + + assertTrue(deleted.comment_view.comment.deleted) + } + + @Test + fun `user can vote on a comment`() = + runBlocking { + val comment = adminApi.newComment() + + val up = user.api.createCommentLike(CreateCommentLike(comment.comment.id, VoteAction.UpVote)).getOrThrow() + assertEquals(VoteAction.UpVote, up.comment_view.comment_actions?.vote) + + val down = user.api.createCommentLike(CreateCommentLike(comment.comment.id, VoteAction.DownVote)).getOrThrow() + assertEquals(VoteAction.DownVote, down.comment_view.comment_actions?.vote) + } + + @Test + fun `user can save and unsave a comment`() = + runBlocking { + val comment = adminApi.newComment() + + val saved = user.api.saveComment(SaveComment(comment.comment.id, save = true)).getOrThrow() + assertNotNull(saved.comment_view.comment_actions?.saved_at) + + val unsaved = user.api.saveComment(SaveComment(comment.comment.id, save = false)).getOrThrow() + assertNull(unsaved.comment_view.comment_actions?.saved_at) + } + + @Test + fun `user can report a comment`() = + runBlocking { + val comment = adminApi.newComment() + + val report = user.api.createCommentReport(CreateCommentReport(comment.comment.id, reason = "E2E report")).getOrThrow() + + assertEquals("E2E report", report.comment_report_view.comment_report.reason) } } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1CommunitiesE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1CommunitiesE2eTest.kt new file mode 100644 index 000000000..9b87b643f --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1CommunitiesE2eTest.kt @@ -0,0 +1,80 @@ +import it.vercruysse.lemmyapi.datatypes.BlockCommunity +import it.vercruysse.lemmyapi.datatypes.FollowCommunity +import it.vercruysse.lemmyapi.datatypes.GetCommunity +import it.vercruysse.lemmyapi.datatypes.ListCommunities +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1CommunitiesE2eTest : LemmyV1E2eTest() { + @Test + fun `anonymous get community by name and id`() = + runBlocking { + val byName = anonApi.getCommunity(GetCommunity(name = SEED_COMMUNITY_NAME)).getOrThrow() + val byId = anonApi.getCommunity(GetCommunity(id = communityId)).getOrThrow() + + assertEquals(communityId, byName.community_view.community.id) + assertEquals(SEED_COMMUNITY_NAME, byId.community_view.community.name) + assertTrue(byName.community_view.community.local) + } + + @Test + fun `anonymous community list contains the seeded community`() = + runBlocking { + val response = anonApi.listCommunities(ListCommunities()).getOrThrow() + + assertTrue(response.items.any { it.community.id == communityId }) + } + + @Test + fun `community list can be paged`() = + runBlocking { + val created = List(3) { adminApi.newCommunity() } + + val pages = fetchPages { cursor -> anonApi.listCommunities(ListCommunities(limit = 2, page_cursor = cursor)) } + val ids = pages.flatten().map { it.community.id } + + assertTrue(pages.size >= 2, "Expected multiple pages but got ${pages.size}") + assertEquals(2, pages.first().size) + assertEquals(ids.distinct(), ids, "Pages must not repeat communities") + assertTrue(ids.containsAll(created)) + } + + @Test + fun `user can create a community and becomes its moderator`() = + runBlocking { + val creator = newUser() + + val id = creator.api.newCommunity() + + assertTrue(creator.api.getMyUser().getOrThrow().moderates.any { it.community.id == id }) + assertEquals(id, anonApi.getCommunity(GetCommunity(id = id)).getOrThrow().community_view.community.id) + } + + @Test + fun `user can follow and unfollow a community`() = + runBlocking { + val follower = newUser() + val community = adminApi.newCommunity() + + follower.api.followCommunity(FollowCommunity(community, follow = true)).getOrThrow() + assertTrue(follower.api.getMyUser().getOrThrow().follows.any { it.community.id == community }) + + follower.api.followCommunity(FollowCommunity(community, follow = false)).getOrThrow() + assertTrue(follower.api.getMyUser().getOrThrow().follows.none { it.community.id == community }) + } + + @Test + fun `user can block and unblock a community`() = + runBlocking { + val blocker = newUser() + val community = adminApi.newCommunity() + + blocker.api.blockCommunity(BlockCommunity(community, block = true)).getOrThrow() + assertEquals(listOf(community), blocker.api.getMyUser().getOrThrow().community_blocks.map { it.id }) + + blocker.api.blockCommunity(BlockCommunity(community, block = false)).getOrThrow() + assertTrue(blocker.api.getMyUser().getOrThrow().community_blocks.isEmpty()) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt b/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt new file mode 100644 index 000000000..9060b1317 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt @@ -0,0 +1,201 @@ +import it.vercruysse.lemmyapi.LemmyApiBaseController +import it.vercruysse.lemmyapi.LemmyApiClient +import it.vercruysse.lemmyapi.LemmyApiOptions +import it.vercruysse.lemmyapi.LemmyAuth +import it.vercruysse.lemmyapi.LemmyInstance +import it.vercruysse.lemmyapi.LemmyVersion +import it.vercruysse.lemmyapi.datatypes.CreateComment +import it.vercruysse.lemmyapi.datatypes.CreateCommunity +import it.vercruysse.lemmyapi.datatypes.CreatePost +import it.vercruysse.lemmyapi.datatypes.EditSite +import it.vercruysse.lemmyapi.datatypes.Login +import it.vercruysse.lemmyapi.datatypes.Register +import it.vercruysse.lemmyapi.enums.RegistrationMode +import kotlinx.coroutines.runBlocking +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.Network +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import org.testcontainers.utility.MountableFile +import java.time.Duration +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.seconds + +class E2eUser(val api: LemmyApiBaseController, val username: String, val personId: Long) + +/** Docker stack (Postgres + pict-rs + Lemmy) plus the data every test class relies on. */ +internal class LemmyV1E2eEnvironment { + private val network = Network.newNetwork() + private val postgres = + LemmyContainer("postgres:16.4-alpine") + .withNetwork(network) + .withNetworkAliases("postgres") + .withEnv("POSTGRES_USER", "lemmy") + .withEnv("POSTGRES_PASSWORD", "password") + .withEnv("POSTGRES_DB", "lemmy") + .withExposedPorts(POSTGRES_PORT) + .waitingFor(Wait.forListeningPort()) + private val pictrs = + LemmyContainer("asonix/pictrs:0.5.16") + .withNetwork(network) + .withNetworkAliases("pictrs") + .withEnv("PICTRS__SERVER__API_KEY", "lemmy-e2e-pictrs-key") + .withExposedPorts(PICTRS_PORT) + .waitingFor(Wait.forListeningPort()) + private val lemmy = + LemmyContainer("dessalines/lemmy:$LEMMY_VERSION") + .withNetwork(network) + .withEnv("LEMMY_DISABLE_ACTIVITY_SENDING", "true") + .withEnv("RUST_LOG", "warn") + .withCopyFileToContainer( + MountableFile.forClasspathResource("lemmy-e2e-config.hjson"), + "/config/config.hjson", + ) + .withExposedPorts(LEMMY_PORT) + .waitingFor( + Wait.forHttp("/api/v4/site") + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(4)), + ) + + private val client = + LemmyApiClient( + options = + LemmyApiOptions( + requestTimeout = 30.seconds, + maxRetries = 5, + userAgent = "LemmyBackwardsCompatibleAPI-E2E", + ), + ) + private val userCounter = AtomicInteger() + private val version = LemmyVersion(LEMMY_VERSION) + + lateinit var anon: LemmyApiBaseController + private set + lateinit var admin: LemmyApiBaseController + private set + lateinit var user: E2eUser + private set + + var communityId = 0L + private set + var postId = 0L + private set + var commentId = 0L + private set + var seededPostIds: List = emptyList() + private set + var seededCommentIds: List = emptyList() + private set + + fun start() { + try { + postgres.start() + pictrs.start() + lemmy.start() + runBlocking { configureSite() } + // Lemmy only reads its rate limits at startup, so restart it to pick up the change + lemmy.stop() + lemmy.start() + runBlocking { seed() } + } catch (t: Throwable) { + stop() + throw t + } + } + + suspend fun newUser(): E2eUser { + val username = "e2eu${userCounter.incrementAndGet()}" + val jwt = anon.register(Register(username, USER_PASSWORD, USER_PASSWORD)).getOrThrow().jwt + checkNotNull(jwt) { "Registration for $username did not return a token" } + val api = connect(LemmyAuth.Bearer(jwt)) + return E2eUser(api, username, api.getMyUser().getOrThrow().local_user_view.person.id) + } + + private fun connect(auth: LemmyAuth = LemmyAuth.Anonymous): LemmyApiBaseController { + val instance = LemmyInstance("http://${lemmy.host}:${lemmy.getMappedPort(LEMMY_PORT)}") + return client.connectForVersion(instance, auth, version).getOrThrow() + } + + private suspend fun loginAdmin(): LemmyApiBaseController { + val jwt = connect().login(Login(ADMIN_USERNAME, ADMIN_PASSWORD)).getOrThrow().jwt + checkNotNull(jwt) { "Lemmy did not return a token for the admin user" } + return connect(LemmyAuth.Bearer(jwt)) + } + + // Open registration and lift the rate limits, all tests come from the same IP + private suspend fun configureSite() { + loginAdmin().editSite( + EditSite( + registration_mode = RegistrationMode.Open, + rate_limit_message_max_requests = UNLIMITED, + rate_limit_post_max_requests = UNLIMITED, + rate_limit_register_max_requests = UNLIMITED, + rate_limit_comment_max_requests = UNLIMITED, + rate_limit_search_max_requests = UNLIMITED, + ), + ).getOrThrow() + } + + private suspend fun seed() { + anon = connect() + admin = loginAdmin() + + communityId = + admin.createCommunity(CreateCommunity(name = SEED_COMMUNITY_NAME, title = "E2E Test Community")) + .getOrThrow().community_view.community.id + + val posts = mutableListOf() + posts += admin.createSeedPost(SEED_POST_TITLE, SEED_POST_BODY) + postId = posts.single() + repeat(SEED_PAGING_COUNT) { posts += admin.createSeedPost("$SEED_PAGING_POST_PREFIX ${it + 1}", null) } + seededPostIds = posts + + val comments = mutableListOf() + comments += admin.createSeedComment(SEED_COMMENT_CONTENT) + commentId = comments.single() + repeat(SEED_PAGING_COUNT) { comments += admin.createSeedComment("$SEED_PAGING_COMMENT_PREFIX ${it + 1}") } + seededCommentIds = comments + + user = newUser() + } + + private suspend fun LemmyApiBaseController.createSeedPost( + title: String, + body: String?, + ): Long = + createPost(CreatePost(name = title, body = body, community_id = communityId)) + .getOrThrow().post_view.post.id + + private suspend fun LemmyApiBaseController.createSeedComment(content: String): Long = + createComment(CreateComment(content = content, post_id = postId)).getOrThrow().comment_view.comment.id + + fun stop() { + runCatching { client.close() } + runCatching { lemmy.stop() } + runCatching { pictrs.stop() } + runCatching { postgres.stop() } + runCatching { network.close() } + } + + private class LemmyContainer(image: String) : GenericContainer(DockerImageName.parse(image)) + + companion object { + const val LEMMY_VERSION = "1.0.0-beta.2" + const val ADMIN_USERNAME = "lemmy" + const val ADMIN_PASSWORD = "lemmylemmy" + const val USER_PASSWORD = "e2e-user-password" + const val SEED_COMMUNITY_NAME = "e2ebasic" + const val SEED_POST_TITLE = "E2E seeded post" + const val SEED_POST_BODY = "A post created through the public unified API." + const val SEED_COMMENT_CONTENT = "An E2E comment created through the public unified API." + const val SEED_PAGING_POST_PREFIX = "E2E paging post" + const val SEED_PAGING_COMMENT_PREFIX = "E2E paging comment" + const val SEED_PAGING_COUNT = 5 + + private const val UNLIMITED = 1_000_000L + private const val LEMMY_PORT = 8536 + private const val PICTRS_PORT = 8080 + private const val POSTGRES_PORT = 5432 + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt index 3b9d4762c..e18cb979f 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt @@ -1,136 +1,91 @@ +import it.vercruysse.lemmyapi.DatatypeRoot import it.vercruysse.lemmyapi.LemmyApiBaseController -import it.vercruysse.lemmyapi.LemmyApiClient -import it.vercruysse.lemmyapi.LemmyApiOptions -import it.vercruysse.lemmyapi.LemmyAuth -import it.vercruysse.lemmyapi.LemmyInstance -import it.vercruysse.lemmyapi.LemmyVersion +import it.vercruysse.lemmyapi.datatypes.CommentView import it.vercruysse.lemmyapi.datatypes.CreateComment import it.vercruysse.lemmyapi.datatypes.CreateCommunity import it.vercruysse.lemmyapi.datatypes.CreatePost -import it.vercruysse.lemmyapi.datatypes.Login -import kotlinx.coroutines.runBlocking -import org.testcontainers.containers.GenericContainer -import org.testcontainers.containers.Network -import org.testcontainers.containers.wait.strategy.Wait -import org.testcontainers.utility.DockerImageName -import org.testcontainers.utility.MountableFile -import java.time.Duration -import kotlin.time.Duration.Companion.seconds +import it.vercruysse.lemmyapi.datatypes.PagedResponse +import it.vercruysse.lemmyapi.datatypes.PaginationCursor +import it.vercruysse.lemmyapi.datatypes.PostView +import java.util.concurrent.atomic.AtomicInteger +/** + * Base class for all Lemmy v1 end-to-end tests. + * + * The docker stack and seed data are created once per JVM, on first access, and shared by every subclass. + * Containers are stopped by a shutdown hook (Testcontainers' Ryuk also reaps them if the JVM dies). + * + * Tests that change data should create their own entities (see [newPost], [newComment], [newCommunity], [newUser]) + * so they do not depend on each other. Seeded data is only read. + */ abstract class LemmyV1E2eTest { - protected val api: LemmyApiBaseController get() = env.api - protected val postId: Long get() = env.postId + protected val anonApi: LemmyApiBaseController get() = env.anon + protected val adminApi: LemmyApiBaseController get() = env.admin - private class Environment { - private val network = Network.newNetwork() - private val postgres = - LemmyContainer("postgres:16.4-alpine") - .withNetwork(network) - .withNetworkAliases("postgres") - .withEnv("POSTGRES_USER", "lemmy") - .withEnv("POSTGRES_PASSWORD", "password") - .withEnv("POSTGRES_DB", "lemmy") - .withExposedPorts(POSTGRES_PORT) - .waitingFor(Wait.forListeningPort()) - private val pictrs = - LemmyContainer("asonix/pictrs:0.5.16") - .withNetwork(network) - .withNetworkAliases("pictrs") - .withEnv("PICTRS__SERVER__API_KEY", "lemmy-e2e-pictrs-key") - .withExposedPorts(PICTRS_PORT) - .waitingFor(Wait.forListeningPort()) - private val lemmy = - LemmyContainer("dessalines/lemmy:$LEMMY_VERSION") - .withNetwork(network) - .withEnv("LEMMY_DISABLE_ACTIVITY_SENDING", "true") - .withEnv("RUST_LOG", "warn") - .withCopyFileToContainer( - MountableFile.forClasspathResource("lemmy-e2e-config.hjson"), - "/config/config.hjson", - ) - .withExposedPorts(LEMMY_PORT) - .waitingFor( - Wait.forHttp("/api/v4/site") - .forStatusCode(200) - .withStartupTimeout(Duration.ofMinutes(4)), - ) + /** Regular (non admin) user that is shared by all tests, create a new one with [newUser] if you change its state. */ + protected val user: E2eUser get() = env.user - private val client = - LemmyApiClient( - options = - LemmyApiOptions( - requestTimeout = 30.seconds, - maxRetries = 5, - userAgent = "LemmyBackwardsCompatibleAPI-E2E", - ), - ) + protected val communityId: Long get() = env.communityId + protected val postId: Long get() = env.postId + protected val commentId: Long get() = env.commentId - lateinit var api: LemmyApiBaseController - private set - var postId = 0L - private set + /** Seeded post/comment ids (the main seed entity first) all created by the admin. */ + protected val seededPostIds: List get() = env.seededPostIds + protected val seededCommentIds: List get() = env.seededCommentIds - fun start() { - try { - postgres.start() - pictrs.start() - lemmy.start() - seed() - } catch (t: Throwable) { - stop() - throw t - } - } + protected suspend fun newUser(): E2eUser = env.newUser() - private fun seed() = - runBlocking { - val instance = LemmyInstance("http://${lemmy.host}:${lemmy.getMappedPort(LEMMY_PORT)}") - val version = LemmyVersion(LEMMY_VERSION) + protected suspend fun LemmyApiBaseController.newCommunity(): Long = + createCommunity(CreateCommunity(name = unique("e2ec"), title = "E2E community")) + .getOrThrow().community_view.community.id - val admin = client.connectForVersion(instance, version).getOrThrow() - val jwt = admin.login(Login(ADMIN_USERNAME, ADMIN_PASSWORD)).getOrThrow().jwt - checkNotNull(jwt) { "Lemmy did not return a token for the admin user" } - admin.updateAuth(LemmyAuth.Bearer(jwt)) + protected suspend fun LemmyApiBaseController.newPost( + title: String = unique("E2E post"), + body: String? = "E2E post body", + inCommunity: Long = communityId, + ): PostView = + createPost(CreatePost(name = title, body = body, community_id = inCommunity)).getOrThrow().post_view - val community = - admin.createCommunity(CreateCommunity(name = SEED_COMMUNITY_NAME, title = "E2E Test Community")) - .getOrThrow().community_view.community - val post = - admin.createPost( - CreatePost(name = SEED_POST_TITLE, body = SEED_POST_BODY, community_id = community.id), - ).getOrThrow().post_view.post - admin.createComment(CreateComment(content = SEED_COMMENT_CONTENT, post_id = post.id)).getOrThrow() + protected suspend fun LemmyApiBaseController.newComment( + content: String = unique("E2E comment"), + onPost: Long = postId, + parent: Long? = null, + ): CommentView = + createComment(CreateComment(content = content, post_id = onPost, parent_id = parent)) + .getOrThrow().comment_view - postId = post.id - api = client.connectForVersion(instance, version).getOrThrow() - } - - fun stop() { - runCatching { client.close() } - runCatching { lemmy.stop() } - runCatching { pictrs.stop() } - runCatching { postgres.stop() } - runCatching { network.close() } - } + /** Follows `next_page` until exhausted (or [maxPages]) and returns the items of each page. */ + protected suspend fun fetchPages( + maxPages: Int = 20, + fetch: suspend (PaginationCursor?) -> Result>, + ): List> { + val pages = mutableListOf>() + var cursor: PaginationCursor? = null + do { + val page = fetch(cursor).getOrThrow() + pages += page.items + cursor = page.next_page + } while (cursor != null && pages.size < maxPages) + return pages } - private class LemmyContainer(image: String) : GenericContainer(DockerImageName.parse(image)) - protected companion object { - const val LEMMY_VERSION = "1.0.0-beta.2" - const val ADMIN_USERNAME = "lemmy" - const val ADMIN_PASSWORD = "lemmylemmy" - const val SEED_COMMUNITY_NAME = "e2ebasic" - const val SEED_POST_TITLE = "E2E seeded post" - const val SEED_POST_BODY = "A post created through the public unified API." - const val SEED_COMMENT_CONTENT = "An E2E comment created through the public unified API." + const val LEMMY_VERSION = LemmyV1E2eEnvironment.LEMMY_VERSION + const val ADMIN_USERNAME = LemmyV1E2eEnvironment.ADMIN_USERNAME + const val ADMIN_PASSWORD = LemmyV1E2eEnvironment.ADMIN_PASSWORD + const val USER_PASSWORD = LemmyV1E2eEnvironment.USER_PASSWORD + const val SEED_COMMUNITY_NAME = LemmyV1E2eEnvironment.SEED_COMMUNITY_NAME + const val SEED_POST_TITLE = LemmyV1E2eEnvironment.SEED_POST_TITLE + const val SEED_POST_BODY = LemmyV1E2eEnvironment.SEED_POST_BODY + const val SEED_COMMENT_CONTENT = LemmyV1E2eEnvironment.SEED_COMMENT_CONTENT + const val SEED_PAGING_COUNT = LemmyV1E2eEnvironment.SEED_PAGING_COUNT + + private val uniqueCounter = AtomicInteger() - private const val LEMMY_PORT = 8536 - private const val PICTRS_PORT = 8080 - private const val POSTGRES_PORT = 5432 + fun unique(prefix: String): String = "$prefix${uniqueCounter.incrementAndGet()}" - private val env: Environment by lazy { - Environment().also { + private val env: LemmyV1E2eEnvironment by lazy { + LemmyV1E2eEnvironment().also { Runtime.getRuntime().addShutdownHook(Thread { it.stop() }) it.start() } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1NotificationsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1NotificationsE2eTest.kt new file mode 100644 index 000000000..4d883f8c7 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1NotificationsE2eTest.kt @@ -0,0 +1,79 @@ +import it.vercruysse.lemmyapi.datatypes.CommentView +import it.vercruysse.lemmyapi.datatypes.CreatePrivateMessage +import it.vercruysse.lemmyapi.datatypes.ListNotifications +import it.vercruysse.lemmyapi.datatypes.MarkNotificationAsRead +import it.vercruysse.lemmyapi.datatypes.PrivateMessageView +import it.vercruysse.lemmyapi.enums.NotificationDataType +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1NotificationsE2eTest : LemmyV1E2eTest() { + @Test + fun `private message creates a notification for the recipient`() = + runBlocking { + val sender = newUser() + val recipient = newUser() + + val sent = sender.api.createPrivateMessage(CreatePrivateMessage("Hello e2e", recipient.personId)).getOrThrow() + assertEquals(sender.username, sent.private_message_view.creator.name) + + val notification = + eventually { + recipient.api.listNotifications(ListNotifications(NotificationDataType.PrivateMessage)).getOrThrow().items.single() + } + assertEquals("Hello e2e", (notification.data as PrivateMessageView).private_message.content) + } + + @Test + fun `reply to a comment creates a notification`() = + runBlocking { + val author = newUser() + val replier = newUser() + val post = author.api.newPost() + val comment = author.api.newComment(onPost = post.post.id) + + replier.api.newComment(content = "A reply", onPost = post.post.id, parent = comment.comment.id) + + val notifications = + eventually { + author.api.listNotifications(ListNotifications(NotificationDataType.Reply)).getOrThrow().items.also { + assertTrue(it.isNotEmpty()) + } + } + assertTrue(notifications.any { (it.data as? CommentView)?.comment?.content == "A reply" }) + } + + @Test + fun `notifications can be marked as read`() = + runBlocking { + val sender = newUser() + val recipient = newUser() + sender.api.createPrivateMessage(CreatePrivateMessage("Read me", recipient.personId)).getOrThrow() + val notification = + eventually { + recipient.api.listNotifications(ListNotifications(NotificationDataType.All, unread_only = true)).getOrThrow().items.single() + } + assertEquals(1, recipient.api.getUnreadCounts().getOrThrow().notification_count) + + recipient.api.markNotificationAsRead(MarkNotificationAsRead(notification.notification.id, read = true)).getOrThrow() + + assertEquals(0, recipient.api.getUnreadCounts().getOrThrow().notification_count) + val unread = recipient.api.listNotifications(ListNotifications(NotificationDataType.All, unread_only = true)).getOrThrow() + assertTrue(unread.items.isEmpty()) + } + + @Test + fun `all notifications can be marked as read`() = + runBlocking { + val sender = newUser() + val recipient = newUser() + repeat(2) { sender.api.createPrivateMessage(CreatePrivateMessage("Message $it", recipient.personId)).getOrThrow() } + eventually { assertEquals(2, recipient.api.getUnreadCounts().getOrThrow().notification_count) } + + recipient.api.markAllNotificationsAsRead().getOrThrow() + + assertEquals(0, recipient.api.getUnreadCounts().getOrThrow().notification_count) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt index b603b0f3f..7ac68b986 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1PersonsE2eTest.kt @@ -1,4 +1,11 @@ +import it.vercruysse.lemmyapi.datatypes.BlockPerson +import it.vercruysse.lemmyapi.datatypes.CommentView import it.vercruysse.lemmyapi.datatypes.GetPersonDetails +import it.vercruysse.lemmyapi.datatypes.ListPersonContent +import it.vercruysse.lemmyapi.datatypes.ListPersonSaved +import it.vercruysse.lemmyapi.datatypes.PostView +import it.vercruysse.lemmyapi.datatypes.SavePost +import it.vercruysse.lemmyapi.enums.PersonContentType import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -8,11 +15,73 @@ class LemmyV1PersonsE2eTest : LemmyV1E2eTest() { @Test fun `anonymous person details returns the seeded administrator`() = runBlocking { - val response = api.getPersonDetails(GetPersonDetails(username = ADMIN_USERNAME)).getOrThrow() + val response = anonApi.getPersonDetails(GetPersonDetails(username = ADMIN_USERNAME)).getOrThrow() val person = response.person_view.person assertEquals(ADMIN_USERNAME, person.name) assertTrue(person.local) + assertTrue(response.person_view.is_admin) assertTrue(person.published_at.isNotBlank()) } + + @Test + fun `anonymous person details can be fetched by id`() = + runBlocking { + val response = anonApi.getPersonDetails(GetPersonDetails(person_id = user.personId)).getOrThrow() + + assertEquals(user.username, response.person_view.person.name) + } + + @Test + fun `person content can be paged`() = + runBlocking { + val pages = + fetchPages { cursor -> + anonApi.listPersonContent(ListPersonContent(username = ADMIN_USERNAME, limit = 4, page_cursor = cursor)) + } + val items = pages.flatten() + + assertTrue(pages.size >= 3, "Expected multiple pages but got ${pages.size}") + assertEquals(4, pages.first().size) + assertEquals(items.distinct().size, items.size, "Pages must not repeat content") + assertTrue(items.filterIsInstance().map { it.post.id }.containsAll(seededPostIds)) + assertTrue(items.filterIsInstance().map { it.comment.id }.containsAll(seededCommentIds)) + } + + @Test + fun `person content can be filtered by type`() = + runBlocking { + val posts = + anonApi.listPersonContent(ListPersonContent(username = ADMIN_USERNAME, type_ = PersonContentType.Posts)).getOrThrow() + val comments = + anonApi.listPersonContent(ListPersonContent(username = ADMIN_USERNAME, type_ = PersonContentType.Comments)).getOrThrow() + + assertTrue(posts.items.isNotEmpty() && posts.items.all { it is PostView }) + assertTrue(comments.items.isNotEmpty() && comments.items.all { it is CommentView }) + } + + @Test + fun `saved content shows up in the saved list`() = + runBlocking { + val saver = newUser() + val post = adminApi.newPost() + saver.api.savePost(SavePost(post.post.id, save = true)).getOrThrow() + + val saved = saver.api.listPersonSaved(ListPersonSaved()).getOrThrow() + + assertEquals(listOf(post.post.id), saved.items.filterIsInstance().map { it.post.id }) + } + + @Test + fun `user can block and unblock another person`() = + runBlocking { + val blocker = newUser() + val target = newUser() + + blocker.api.blockPerson(BlockPerson(target.personId, block = true)).getOrThrow() + assertEquals(listOf(target.personId), blocker.api.getMyUser().getOrThrow().person_blocks.map { it.id }) + + blocker.api.blockPerson(BlockPerson(target.personId, block = false)).getOrThrow() + assertTrue(blocker.api.getMyUser().getOrThrow().person_blocks.isEmpty()) + } } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt index d85f87db4..2f002697e 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1PostsE2eTest.kt @@ -1,19 +1,184 @@ +import it.vercruysse.lemmyapi.datatypes.CreatePostLike +import it.vercruysse.lemmyapi.datatypes.CreatePostReport +import it.vercruysse.lemmyapi.datatypes.DeletePost +import it.vercruysse.lemmyapi.datatypes.EditPost +import it.vercruysse.lemmyapi.datatypes.FollowCommunity +import it.vercruysse.lemmyapi.datatypes.GetPost import it.vercruysse.lemmyapi.datatypes.GetPosts +import it.vercruysse.lemmyapi.datatypes.HidePost +import it.vercruysse.lemmyapi.datatypes.ListReports +import it.vercruysse.lemmyapi.datatypes.MarkPostAsRead +import it.vercruysse.lemmyapi.datatypes.PostReportView +import it.vercruysse.lemmyapi.datatypes.SavePost +import it.vercruysse.lemmyapi.enums.ListingType +import it.vercruysse.lemmyapi.enums.ReportType +import it.vercruysse.lemmyapi.enums.SortType +import it.vercruysse.lemmyapi.enums.VoteAction import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class LemmyV1PostsE2eTest : LemmyV1E2eTest() { @Test fun `anonymous post list returns the seeded post`() = runBlocking { - val response = api.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME)).getOrThrow() + val response = anonApi.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME)).getOrThrow() val post = response.items.single { it.post.id == postId } assertEquals(SEED_POST_TITLE, post.post.name) assertEquals(SEED_POST_BODY, post.post.body) assertEquals(ADMIN_USERNAME, post.creator.name) + assertEquals(SEED_COMMUNITY_NAME, post.community.name) assertTrue(post.post.published_at.isNotBlank()) + assertNull(post.post_actions) + } + + @Test + fun `anonymous post list can be paged`() = + runBlocking { + val pages = + fetchPages { cursor -> + anonApi.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME, limit = 2, page_cursor = cursor)) + } + val ids = pages.flatten().map { it.post.id } + + assertTrue(pages.size >= 3, "Expected multiple pages but got ${pages.size}") + assertEquals(2, pages.first().size) + assertEquals(ids.distinct(), ids, "Pages must not repeat posts") + assertTrue(ids.containsAll(seededPostIds)) + } + + @Test + fun `post list sort order is respected`() = + runBlocking { + val newest = anonApi.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME, sort = SortType.New)).getOrThrow() + val oldest = anonApi.getPosts(GetPosts(community_name = SEED_COMMUNITY_NAME, sort = SortType.Old)).getOrThrow() + + assertEquals(oldest.items.map { it.post.id }.sorted(), oldest.items.map { it.post.id }) + assertEquals(newest.items.map { it.post.id }.sortedDescending(), newest.items.map { it.post.id }) + } + + @Test + fun `anonymous get post returns the post and its community`() = + runBlocking { + val response = anonApi.getPost(GetPost(id = postId)).getOrThrow() + + assertEquals(SEED_POST_TITLE, response.post_view.post.name) + assertEquals(communityId, response.community_view.community.id) + } + + @Test + fun `user can create a post`() = + runBlocking { + val post = user.api.newPost(title = unique("Created post "), body = "Some body") + + assertEquals(user.username, post.creator.name) + assertEquals("Some body", post.post.body) + assertEquals(post.post.id, anonApi.getPost(GetPost(id = post.post.id)).getOrThrow().post_view.post.id) + } + + @Test + fun `user can edit a post`() = + runBlocking { + val post = user.api.newPost() + + val edited = user.api.editPost(EditPost(post_id = post.post.id, name = "Edited title", body = "Edited body")).getOrThrow() + + assertEquals("Edited title", edited.post_view.post.name) + assertEquals("Edited body", anonApi.getPost(GetPost(id = post.post.id)).getOrThrow().post_view.post.body) + } + + @Test + fun `user can delete a post`() = + runBlocking { + val post = user.api.newPost() + + val deleted = user.api.deletePost(DeletePost(post_id = post.post.id, deleted = true)).getOrThrow() + assertTrue(deleted.post_view.post.deleted) + + val restored = user.api.deletePost(DeletePost(post_id = post.post.id, deleted = false)).getOrThrow() + assertTrue(!restored.post_view.post.deleted) + } + + @Test + fun `user can vote on a post`() = + runBlocking { + val post = adminApi.newPost() + + val up = user.api.createPostLike(CreatePostLike(post.post.id, VoteAction.UpVote)).getOrThrow() + assertEquals(VoteAction.UpVote, up.post_view.post_actions?.vote) + + val down = user.api.createPostLike(CreatePostLike(post.post.id, VoteAction.DownVote)).getOrThrow() + assertEquals(VoteAction.DownVote, down.post_view.post_actions?.vote) + + val none = user.api.createPostLike(CreatePostLike(post.post.id, VoteAction.NoVote)).getOrThrow() + assertEquals(VoteAction.NoVote, none.post_view.post_actions?.vote ?: VoteAction.NoVote) + } + + @Test + fun `user can save and unsave a post`() = + runBlocking { + val post = adminApi.newPost() + + val saved = user.api.savePost(SavePost(post.post.id, save = true)).getOrThrow() + assertNotNull(saved.post_view.post_actions?.saved_at) + assertNotNull(user.api.getPost(GetPost(id = post.post.id)).getOrThrow().post_view.post_actions?.saved_at) + + val unsaved = user.api.savePost(SavePost(post.post.id, save = false)).getOrThrow() + assertNull(unsaved.post_view.post_actions?.saved_at) + } + + @Test + fun `user can mark a post as read`() = + runBlocking { + val post = adminApi.newPost() + + val read = user.api.markPostAsRead(MarkPostAsRead(post.post.id, read = true)).getOrThrow() + + assertNotNull(read.post_view.post_actions?.read_at) + } + + @Test + fun `user can hide a post`() = + runBlocking { + val post = adminApi.newPost() + + user.api.hidePost(HidePost(post.post.id, hide = true)).getOrThrow() + + assertNotNull(user.api.getPost(GetPost(id = post.post.id)).getOrThrow().post_view.post_actions?.hidden_at) + } + + @Test + fun `user can report a post`() = + runBlocking { + val post = adminApi.newPost() + + val report = user.api.createPostReport(CreatePostReport(post.post.id, reason = "E2E report")).getOrThrow() + + assertEquals("E2E report", report.post_report_view.post_report.reason) + + val reports = adminApi.listReports(ListReports(type_ = ReportType.Posts)).getOrThrow() + assertTrue( + reports.items.filterIsInstance().any { it.post_report.id == report.post_report_view.post_report.id }, + ) + } + + @Test + fun `subscribed feed contains posts of followed communities`() = + runBlocking { + val follower = newUser() + val community = adminApi.newCommunity() + val post = adminApi.newPost(inCommunity = community) + assertTrue( + follower.api.getPosts(GetPosts(type_ = ListingType.Subscribed)).getOrThrow().items.none { it.post.id == post.post.id }, + ) + + follower.api.followCommunity(FollowCommunity(community, follow = true)).getOrThrow() + + val feed = follower.api.getPosts(GetPosts(type_ = ListingType.Subscribed)).getOrThrow() + assertTrue(feed.items.any { it.post.id == post.post.id }) } } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1SearchE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1SearchE2eTest.kt new file mode 100644 index 000000000..90639953d --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1SearchE2eTest.kt @@ -0,0 +1,61 @@ +import it.vercruysse.lemmyapi.datatypes.Search +import it.vercruysse.lemmyapi.enums.SearchType +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1SearchE2eTest : LemmyV1E2eTest() { + @Test + fun `search finds posts`() = + runBlocking { + val response = anonApi.search(Search(search_term = SEED_POST_TITLE, type_ = SearchType.Posts)).getOrThrow() + + assertTrue(response.posts.any { it.post.id == postId }) + } + + @Test + fun `search finds comments`() = + runBlocking { + val response = anonApi.search(Search(search_term = SEED_COMMENT_CONTENT, type_ = SearchType.Comments)).getOrThrow() + + assertTrue(response.comments.any { it.comment.id == commentId }) + } + + @Test + fun `search finds communities`() = + runBlocking { + val response = anonApi.search(Search(search_term = SEED_COMMUNITY_NAME, type_ = SearchType.Communities)).getOrThrow() + + assertTrue(response.communities.any { it.community.id == communityId }) + } + + @Test + fun `search finds users`() = + runBlocking { + val response = anonApi.search(Search(search_term = user.username, type_ = SearchType.Users)).getOrThrow() + + assertTrue(response.persons.any { it.person.id == user.personId }) + } + + @Test + fun `search results can be paged`() = + runBlocking { + val ids = mutableListOf() + var pages = 0 + var cursor: String? = null + do { + val response = + anonApi.search( + Search(search_term = "E2E paging post", type_ = SearchType.Posts, limit = 2, page_cursor = cursor), + ).getOrThrow() + ids += response.posts.map { it.post.id } + cursor = response.next_page + pages++ + } while (cursor != null && pages < 20) + + assertTrue(pages >= 3, "Expected multiple pages but got $pages") + assertEquals(ids.distinct(), ids, "Pages must not repeat posts") + assertTrue(ids.containsAll(seededPostIds.drop(1))) + } +} diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1SiteE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1SiteE2eTest.kt new file mode 100644 index 000000000..c4ca64a57 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1SiteE2eTest.kt @@ -0,0 +1,29 @@ +import it.vercruysse.lemmyapi.enums.RegistrationMode +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1SiteE2eTest : LemmyV1E2eTest() { + @Test + fun `anonymous site info describes the instance`() = + runBlocking { + val site = anonApi.getSite().getOrThrow() + + assertEquals("lemmy-e2e", site.site_view.site.name) + assertEquals(RegistrationMode.Open, site.site_view.local_site.registration_mode) + assertTrue(site.version.startsWith("1.0"), "Unexpected version ${site.version}") + assertTrue(site.admins.any { it.person.name == ADMIN_USERNAME }) + assertTrue(site.all_languages.isNotEmpty()) + } + + @Test + fun `site counters include the seeded content`() = + runBlocking { + val local = anonApi.getSite().getOrThrow().site_view.local_site + + assertTrue(local.posts >= seededPostIds.size) + assertTrue(local.comments >= seededCommentIds.size) + assertTrue(local.communities >= 1) + } +} diff --git a/app/src/jvmE2eTest/kotlin/Utils.kt b/app/src/jvmE2eTest/kotlin/Utils.kt new file mode 100644 index 000000000..dea41e01c --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/Utils.kt @@ -0,0 +1,22 @@ +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** Retries [block] until it stops throwing an assertion/check failure, for state that is not instantly visible. */ +suspend fun eventually( + timeoutSeconds: Int = 10, + block: suspend () -> T, +): T { + val deadline = System.nanoTime() + timeoutSeconds * 1_000_000_000L + while (true) { + try { + return block() + } catch (e: AssertionError) { + if (System.nanoTime() > deadline) throw e + } catch (e: NoSuchElementException) { + if (System.nanoTime() > deadline) throw e + } catch (e: IllegalStateException) { + if (System.nanoTime() > deadline) throw e + } + delay(500.milliseconds) + } +} From 907da269ca2d888923dbaf3865564aad14b5d708 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Mon, 19 Oct 2026 18:50:38 +0200 Subject: [PATCH 3/5] Add image e2e tests --- .../it/vercruysse/lemmyapi/utils/Ktor.kt | 2 +- .../kotlin/LemmyV1E2eEnvironment.kt | 1 + .../jvmE2eTest/kotlin/LemmyV1MediaE2eTest.kt | 81 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 app/src/jvmE2eTest/kotlin/LemmyV1MediaE2eTest.kt diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/utils/Ktor.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/utils/Ktor.kt index c0586acc8..51bb1daa6 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/utils/Ktor.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/utils/Ktor.kt @@ -56,7 +56,7 @@ suspend inline fun HttpClient.postUploadResult( MultiPartFormDataContent( formData { append( - "image", // TODO: this might need to be images[] + "images[]", image, Headers.build { append(HttpHeaders.ContentDisposition, "filename=\"image.jpg\"") diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt b/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt index 9060b1317..ce2a16ef3 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1E2eEnvironment.kt @@ -133,6 +133,7 @@ internal class LemmyV1E2eEnvironment { rate_limit_register_max_requests = UNLIMITED, rate_limit_comment_max_requests = UNLIMITED, rate_limit_search_max_requests = UNLIMITED, + rate_limit_image_max_requests = UNLIMITED, ), ).getOrThrow() } diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1MediaE2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1MediaE2eTest.kt new file mode 100644 index 000000000..d29c56b79 --- /dev/null +++ b/app/src/jvmE2eTest/kotlin/LemmyV1MediaE2eTest.kt @@ -0,0 +1,81 @@ +import it.vercruysse.lemmyapi.datatypes.DeleteImageParams +import it.vercruysse.lemmyapi.datatypes.ListMedia +import kotlinx.coroutines.runBlocking +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LemmyV1MediaE2eTest : LemmyV1E2eTest() { + @Test + fun `user can upload an image and list it`() = + runBlocking { + val uploader = newUser() + + val upload = uploader.api.uploadImage(PNG).getOrThrow() + + assertTrue(upload.image_url.contains(upload.filename), "Image url ${upload.image_url} should contain ${upload.filename}") + val media = uploader.api.listMedia(ListMedia()).getOrThrow().items + assertEquals(listOf(upload.filename), media.map { it.local_image.pictrs_alias }) + assertEquals(uploader.username, media.single().person.name) + } + + @Test + fun `user can delete an uploaded image`() = + runBlocking { + val uploader = newUser() + val upload = uploader.api.uploadImage(PNG).getOrThrow() + + uploader.api.deleteMedia(DeleteImageParams(upload.delete_filename)).getOrThrow() + + assertTrue(uploader.api.listMedia(ListMedia()).getOrThrow().items.isEmpty()) + } + + @Test + fun `uploaded images can be paged`() = + runBlocking { + val uploader = newUser() + val filenames = List(3) { uploader.api.uploadImage(PNG).getOrThrow().filename } + + val pages = fetchPages { cursor -> uploader.api.listMedia(ListMedia(limit = 2, page_cursor = cursor)) } + + assertEquals(2, pages.size) + assertEquals(2, pages.first().size) + assertEquals(filenames.toSet(), pages.flatten().map { it.local_image.pictrs_alias }.toSet()) + } + + @Test + fun `user can upload an avatar and banner`() = + runBlocking { + val uploader = newUser() + + val avatar = uploader.api.uploadUserAvatar(PNG).getOrThrow() + val banner = uploader.api.uploadUserBanner(PNG).getOrThrow() + + val person = uploader.api.getMyUser().getOrThrow().local_user_view.person + assertEquals(avatar.image_url, person.avatar) + assertEquals(banner.image_url, person.banner) + } + + @Test + fun `anonymous user cannot upload an image`() = + runBlocking { + assertTrue(anonApi.uploadImage(PNG).isFailure) + } + + @Test + fun `uploading something that is not an image fails`() = + runBlocking { + val result = newUser().api.uploadImage("definitely not an image".encodeToByteArray()) + + assertTrue(result.isFailure) + } + + private companion object { + // 1x1 transparent PNG + val PNG: ByteArray = + Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + ) + } +} From 6ac26cbe23505dea1f095ec03aef65db93739863 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Mon, 19 Oct 2026 18:54:10 +0200 Subject: [PATCH 4/5] Add e2e test CI flow --- .github/workflows/e2e.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..d614c3bed --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,31 @@ +name: E2E + +# Manual only: run from the Actions tab ("Run workflow") or `gh workflow run e2e.yml` +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + - run: ./gradlew :app:e2eTest + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-reports + path: app/build/reports/tests/e2eTest + if-no-files-found: ignore From d642670f9466b22cdbacd1bf047c762a3b54e8bb Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Mon, 19 Oct 2026 19:06:10 +0200 Subject: [PATCH 5/5] remove doc --- app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt index e18cb979f..744c4bd30 100644 --- a/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt +++ b/app/src/jvmE2eTest/kotlin/LemmyV1E2eTest.kt @@ -9,15 +9,6 @@ import it.vercruysse.lemmyapi.datatypes.PaginationCursor import it.vercruysse.lemmyapi.datatypes.PostView import java.util.concurrent.atomic.AtomicInteger -/** - * Base class for all Lemmy v1 end-to-end tests. - * - * The docker stack and seed data are created once per JVM, on first access, and shared by every subclass. - * Containers are stopped by a shutdown hook (Testcontainers' Ryuk also reaps them if the JVM dies). - * - * Tests that change data should create their own entities (see [newPost], [newComment], [newCommunity], [newUser]) - * so they do not depend on each other. Seeded data is only read. - */ abstract class LemmyV1E2eTest { protected val anonApi: LemmyApiBaseController get() = env.anon protected val adminApi: LemmyApiBaseController get() = env.admin