A mock NNTP (Network News Transfer Protocol) server built with Kotlin and Ktor, designed to simulate NNTP responses and track command usage. Includes a Testcontainers wrapper and Kotlin client DSL for use in integration tests.
- Kotlin & Ktor: Developed using Kotlin and the Ktor asynchronous framework.
- Configurable Mock Responses: HTTP REST endpoints to add, retrieve, and clear mock responses for specific NNTP commands.
- Command Call Statistics: HTTP REST endpoints to track and retrieve how many times each NNTP command has been called.
- Yenc Support: Automatic yenc encoding via rapidyenc, or provide pre-encoded yenc data for full control.
- Testcontainers Integration: Built-in
MockNntpServerContainerand client DSL for easy use in JUnit 5 integration tests. - Docker: Published to
ghcr.io/skjaere/mock-nntp-server.
Add the dependency via JitPack:
// build.gradle.kts
repositories {
maven("https://jitpack.io")
}
dependencies {
testImplementation("com.github.skjaere:mock-nntp-server:v0.1.0")
testImplementation("org.testcontainers:testcontainers:2.0.3")
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.3")
}MockNntpServerContainer extends Testcontainers' GenericContainer and manages the mock NNTP server Docker image lifecycle.
import io.skjaere.mocknntp.testcontainer.MockNntpServerContainer
val container = MockNntpServerContainer() // defaults to "ghcr.io/skjaere/mock-nntp-server:latest"
container.start()
// Access mapped ports
val nntpHost = container.nntpHost // container hostname
val nntpPort = container.nntpPort // mapped NNTP port (1119 inside container)
val httpUrl = container.httpUrl // e.g. "http://localhost:32789"
// Get a pre-configured client
val client = container.client
// Clean up
container.stop()You can also provide a custom image name:
val container = MockNntpServerContainer("my-registry/mock-nntp-server:v2")MockNntpClient is an HTTP client that communicates with the mock server's REST API. It is available directly via container.client or can be instantiated standalone:
import io.skjaere.mocknntp.testcontainer.client.MockNntpClient
val client = MockNntpClient("http://localhost:8081")
// Add expectations (see DSL section below)
client.addExpectation { /* ... */ }
// Add a yenc body expectation (server auto-encodes the raw bytes)
client.addYencBodyExpectation(
articleId = "<file.part1@example.com>",
data = fileBytes,
filename = "archive.rar"
)
// Add a raw yenc body expectation (pre-encoded, for multipart or custom yenc formats)
client.addRawYencBodyExpectation(
articleId = "<file.part2@example.com>",
rawYencData = preBuiltYencBytes
)
// Retrieve command call statistics
val stats: Map<String, Int> = client.getStats()
// Clear state
client.clearExpectations() // clears command-level mocks (also clears yenc mocks and stats)
client.clearYencBodyExpectations() // clears only yenc body mocks
client.clearStats() // clears only statistics
client.close()The DSL provides a type-safe builder for configuring mock expectations via addExpectation.
client.addExpectation {
given {
command = NntpCommand.ARTICLE
argument = "<articleId>" // optional
}
thenRespond {
withTextResponse {
status = 220 // default
body = "220 Article follows\r\nSubject: Test\r\n\r\nBody content"
}
}
}client.addExpectation {
given {
command = NntpCommand.BODY
}
thenRespond {
withBinaryBodyResponse {
status = 222 // default
body = fileBytes // ByteArray, sent as base64 over NNTP
}
}
}For yenc-encoded responses keyed by article ID. The server automatically yenc-encodes the provided raw bytes. The argument in the given block is used as the article ID.
client.addExpectation {
given {
command = NntpCommand.BODY
argument = "<file.part1@example.com>" // required for yenc
}
thenRespond {
withYencBodyResponse {
body = rawFileBytes // ByteArray, will be yenc-encoded server-side
filename = "data.bin" // optional, derived from articleId if omitted
}
}
}The NntpCommand enum defines all supported NNTP commands for the given block:
ARTICLE, BODY, HEAD, STAT, GROUP, LISTGROUP, LAST, NEXT, POST, QUIT
import io.skjaere.mocknntp.testcontainer.MockNntpServerContainer
import io.skjaere.mocknntp.testcontainer.client.NntpCommand
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers
class MyNntpIntegrationTest {
companion object {
@Container
@JvmStatic
val container = MockNntpServerContainer()
}
@Test
fun `should return yenc-encoded body for article`() = runBlocking {
val client = container.client
val testData = "Hello, Usenet!".toByteArray()
client.addExpectation {
given {
command = NntpCommand.BODY
argument = "<test@example.com>"
}
thenRespond {
withYencBodyResponse {
body = testData
filename = "hello.txt"
}
}
}
// Connect to container.nntpHost:container.nntpPort
// and send: BODY <test@example.com>
// The response will contain yenc-encoded data with =ybegin/=yend headers
val stats = client.getStats()
assert(stats["BODY"] == 1)
client.clearExpectations()
}
}The Docker image is published to GitHub Container Registry:
docker run -p 8081:8081 -p 1119:1119 ghcr.io/skjaere/mock-nntp-server:latest./gradlew jibDockerBuild
docker run -p 8081:8081 -p 1119:1119 ghcr.io/skjaere/mock-nntp-server:latestThe Ktor HTTP server runs on port 8081 and the mock NNTP server on port 1119.
| Method | Endpoint | Description |
|---|---|---|
POST |
/mocks |
Add/update a mock response for an NNTP command |
GET |
/mocks |
List all configured mock responses |
DELETE |
/mocks |
Clear all mock responses |
POST |
/mocks/yenc-body |
Add a yenc body mock (auto-encoded) |
POST |
/mocks/yenc-body/raw |
Add a raw yenc body mock (pre-encoded) |
DELETE |
/mocks/yenc-body |
Clear all yenc body mocks |
| Method | Endpoint | Description |
|---|---|---|
GET |
/stats |
Get command call counts |
DELETE |
/stats |
Clear command call statistics |
When the server receives an NNTP command, it resolves the response in this order:
- Article-keyed yenc body mock -- If the command is
BODYand an article ID is provided, the server checks for a yenc body mock matching that exact article ID. - Command-level mock -- Falls back to the command-keyed mock (e.g., all
BODYcommands return the same response). - 500 error -- If no mock is configured, responds with
500 Command not recognized.
- Kotlin
- Ktor Framework
- Gradle
- Docker / Jib
- Testcontainers 2.x
kotlinx.coroutines- rapidyenc (native yenc encoding via JNA)