Refactoring of cucumber implementation

This commit is contained in:
2019-08-23 13:04:20 +02:00
parent 46885ac599
commit 9b6f3aab88
14 changed files with 259 additions and 189 deletions

View File

@@ -5,4 +5,4 @@ logback_version=1.2.1
postgresjson_version=0.1 postgresjson_version=0.1
koinVersion=2.0.1 koinVersion=2.0.1
jackson_version=2.9.9 jackson_version=2.9.9
cucumber_version=4.3.1 cucumber_version=4.7.1

View File

@@ -17,7 +17,6 @@ import fr.postgresjson.migration.Migrations
import io.ktor.application.Application import io.ktor.application.Application
import io.ktor.application.install import io.ktor.application.install
import io.ktor.auth.Authentication import io.ktor.auth.Authentication
import io.ktor.auth.authenticate
import io.ktor.auth.jwt.jwt import io.ktor.auth.jwt.jwt
import io.ktor.features.AutoHeadResponse import io.ktor.features.AutoHeadResponse
import io.ktor.features.CallLogging import io.ktor.features.CallLogging
@@ -140,6 +139,7 @@ fun Application.module() {
} }
install(Routing) { install(Routing) {
// trace { application.log.trace(it.buildText()) }
authenticate(optional = true) { authenticate(optional = true) {
article(get()) article(get())
auth(get()) auth(get())

View File

@@ -21,8 +21,7 @@ class Config {
} }
object JwtConfig { object JwtConfig {
const val secret = "zAP5MBA4B4Ijz0MZaS48"
private const val secret = "zAP5MBA4B4Ijz0MZaS48"
private const val issuer = "dc-project.fr" private const val issuer = "dc-project.fr"
private const val validityInMs = 36_000_00 * 10 // 10 hours private const val validityInMs = 36_000_00 * 10 // 10 hours
// TODO change to RSA512 // TODO change to RSA512

View File

@@ -1,25 +1,21 @@
import cucumber.api.CucumberOptions import feature.KtorServerContext
import cucumber.api.Scenario
import cucumber.api.java8.En
import cucumber.api.junit.Cucumber
import feature.Context
import fr.dcproject.config import fr.dcproject.config
import fr.dcproject.module import fr.dcproject.module
import fr.dcproject.utils.LoggerDelegate import fr.dcproject.utils.LoggerDelegate
import fr.postgresjson.connexion.Connection import fr.postgresjson.connexion.Connection
import fr.postgresjson.connexion.Requester import fr.postgresjson.connexion.Requester
import fr.postgresjson.migration.Migrations import fr.postgresjson.migration.Migrations
import io.cucumber.core.api.Scenario
import io.cucumber.java8.En
import io.cucumber.junit.Cucumber
import io.cucumber.junit.CucumberOptions
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.createTestEnvironment
import io.ktor.server.testing.withTestApplication import io.ktor.server.testing.withTestApplication
import io.ktor.util.KtorExperimentalAPI import io.ktor.util.KtorExperimentalAPI
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.test.KoinTest import org.koin.test.KoinTest
import org.koin.test.get import org.koin.test.get
import org.slf4j.Logger import org.slf4j.Logger
import java.util.concurrent.TimeUnit
import feature.Context.Companion.current as contextCurrent
var unitialized: Boolean = false var unitialized: Boolean = false
@@ -29,6 +25,11 @@ var unitialized: Boolean = false
@CucumberOptions(plugin = ["pretty"]) @CucumberOptions(plugin = ["pretty"])
class RunCucumberTest: En, KoinTest { class RunCucumberTest: En, KoinTest {
private val logger: Logger? by LoggerDelegate() private val logger: Logger? by LoggerDelegate()
val ktorContext = KtorServerContext {
module()
}
init { init {
Before(-2) { _: Scenario -> Before(-2) { _: Scenario ->
if (!unitialized) { if (!unitialized) {
@@ -48,11 +49,11 @@ class RunCucumberTest: En, KoinTest {
config.database = "test" config.database = "test"
config.username = "test" config.username = "test"
config.password = "test" config.password = "test"
contextCurrent = Context(TestApplicationEngine(createTestEnvironment()) {}, scenario) ktorContext.start()
} }
After { _: Scenario -> After { _: Scenario ->
contextCurrent.engine.stop(0L, 0L, TimeUnit.MILLISECONDS) ktorContext.stop()
} }
} }

View File

@@ -1,44 +0,0 @@
package feature
import cucumber.api.Scenario
import fr.dcproject.module
import io.ktor.application.Application
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.TestApplicationRequest
import io.ktor.util.KtorExperimentalAPI
@KtorExperimentalLocationsAPI
@KtorExperimentalAPI
class Context(
val engine: TestApplicationEngine,
val scenario: Scenario
) {
companion object {
lateinit var current: Context
}
init {
engine.start()
val moduleFunction: Application.() -> Unit = { module() }
val test: TestApplicationEngine.() -> Unit = {
moduleFunction(application)
}
engine.test()
}
var call: TestApplicationCall? = null
private val requestContextConfigurations: MutableList<TestApplicationRequest.() -> Unit> = mutableListOf()
fun setupRequest(testApplicationRequest: TestApplicationRequest) {
requestContextConfigurations.forEach {
it(testApplicationRequest)
}
}
fun setupNextRequests(requestContextConfiguration: TestApplicationRequest.() -> Unit) = requestContextConfigurations.add(requestContextConfiguration)
}
fun TestApplicationRequest.applyConfigurations() {
Context.current.setupRequest(this)
}

View File

@@ -0,0 +1,56 @@
package feature
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import fr.dcproject.JwtConfig
import fr.dcproject.entity.Citizen
import fr.dcproject.entity.User
import fr.postgresjson.connexion.Requester
import io.cucumber.datatable.DataTable
import io.cucumber.java8.En
import io.ktor.http.HttpHeaders
import org.joda.time.DateTime
import org.koin.test.KoinTest
import org.koin.test.get
import org.koin.test.inject
import java.util.*
import kotlin.random.Random
import fr.dcproject.repository.User as UserRepository
class KtorServerAuthSteps: En, KoinTest {
private val requester: Requester by inject()
init {
When("I have citizen:") { body: DataTable ->
val user = User(username = "jaque_${Random.nextInt(0, 10000)}", plainPassword = "azerty")
requester
.getFunction("insert_user")
.selectOne(user)
val data = body.asMap<String, String>(String::class.java, String::class.java)
val citizen = Citizen(
id = UUID.fromString(data["id"]),
name = Citizen.Name(data["firstName"], data["lastName"]),
birthday = DateTime.now(),
user = user
)
requester
.getFunction("upsert_citizen")
.selectOne(citizen)
}
Given("I am authenticated as an user") {
val id = UUID.randomUUID()
val jwtAsString: String = JWT.create()
.withIssuer("dc-project.fr")
.withClaim("id", id.toString())
.sign(Algorithm.HMAC512(JwtConfig.secret))
val user = User(id = id, username = "user", plainPassword = "azerty")
get<UserRepository>().insert(user)
KtorServerContext.defaultServer.addPreRequestSetup {
addHeader(HttpHeaders.Authorization, "Bearer $jwtAsString")
}
}
}
}

View File

@@ -0,0 +1,54 @@
package feature
import io.ktor.application.Application
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.TestApplicationRequest
import io.ktor.server.testing.createTestEnvironment
import java.util.concurrent.TimeUnit
import kotlin.test.fail
class KtorServerContext(useByDefault: Boolean = true, val module: Application.() -> Unit) {
init { if (useByDefault) setDefault() }
companion object {
lateinit var defaultServer: KtorServerContext
}
private val engine = TestApplicationEngine(createTestEnvironment())
private data class RequestSetup(val setup: TestApplicationRequest.() -> Unit, val keepSetup: Boolean = true)
private val preRequestSetup = mutableListOf<RequestSetup>()
var call: TestApplicationCall? = null
fun addPreRequestSetup(keepSetup: Boolean = true, hook: TestApplicationRequest.() -> Unit) {
preRequestSetup.add(RequestSetup(hook, keepSetup))
}
fun handleRequest(setup: TestApplicationRequest.() -> Unit) =
try {
call = engine.handleRequest {
preRequestSetup.forEach { it.setup(this) }
setup(this)
}
} catch (e: Throwable) {
fail("Request fail, $e")
} finally {
preRequestSetup.removeAll { !it.keepSetup }
}
fun setDefault() {
defaultServer = this
}
fun start() {
engine.start()
module(engine.application)
}
fun stop() {
engine.stop(0L, 0L, TimeUnit.MILLISECONDS)
}
}

View File

@@ -0,0 +1,64 @@
package feature
import com.google.gson.JsonParser
import io.cucumber.datatable.DataTable
import io.cucumber.java8.En
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.setBody
import io.ktor.util.KtorExperimentalAPI
import kotlinx.serialization.ImplicitReflectionSerializer
import org.junit.jupiter.api.Assertions
import org.opentest4j.AssertionFailedError
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@ImplicitReflectionSerializer
@KtorExperimentalAPI
class KtorServerRequestSteps : En {
init {
Given("Next request as headers:") { dataTable: DataTable ->
KtorServerContext.defaultServer.addPreRequestSetup(false) {
dataTable.asMap<String, String>(String::class.java, String::class.java).forEach { key, value ->
this.addHeader(key, value)
}
}
}
Given("I send a {word} request to {string} with body:") { method: String, uri: String, body: String ->
KtorServerContext.defaultServer.handleRequest {
this.method = HttpMethod.parse(method)
this.uri = uri
this.addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(body)
}
}
Given("I send a {word} request to {string}") { method: String, uri: String ->
KtorServerContext.defaultServer.handleRequest {
this.method = HttpMethod.parse(method.toUpperCase())
this.uri = uri
}
}
Then("the response status code should be {int}") { statusCode: Int ->
assertEquals(HttpStatusCode.fromValue(statusCode), KtorServerContext.defaultServer.call?.response?.status())
}
Then("the response should contain object:") { expected: DataTable ->
val call = KtorServerContext.defaultServer.call ?: throw AssertionFailedError("No call")
val response = JsonParser().parse(call.response.content).getAsJsonObject()
expected.asMap<String, String>(String::class.java, String::class.java).forEach { (key, valueExpected) ->
assertTrue(response.has(key))
Assertions.assertEquals(valueExpected, response.get(key).asString)
}
}
Then("print last response") {
print(KtorServerContext.defaultServer.call?.response?.content)
}
}
}

View File

@@ -0,0 +1,55 @@
package feature
import io.cucumber.datatable.DataTable
import io.cucumber.java8.En
import kotlinx.serialization.ImplicitReflectionSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.parse
import kotlin.test.assertEquals
import kotlin.test.fail
@ImplicitReflectionSerializer
class KtorServerRestSteps : En {
init {
Then("the JSON should contain:") { dataTable: DataTable ->
dataTable.asMap<String, String>(String::class.java, String::class.java).forEach { (key, value) ->
val jsonPrimitive = findJsonElement(key) as? JsonPrimitive ?: fail("\"$key\" element isn't json primitive")
assertEquals(jsonPrimitive.content, value)
}
}
Then("the JSON element {word} should have {int} item(s)") { node: String, count: Int ->
val jsonArray = findJsonElement(node) as? JsonArray ?: fail("\"$node\" element isn't json array")
assertEquals(count, jsonArray.size)
}
Then("the JSON should have {int} item(s)") { count: Int ->
val jsonArray = responseJsonElement as? JsonArray ?: fail("The json response isn't array")
assertEquals(count, jsonArray.size)
}
}
private fun findJsonElement(node: String): JsonElement {
var jsonElement: JsonElement = responseJsonElement
val elements = node.split(".")
elements.forEach {
val asArrayIndex = """\d+""".toRegex().find(it)
jsonElement = if (asArrayIndex != null) {
val index = asArrayIndex.groups.first()!!
jsonElement.jsonArray.get(index.value.toInt())
} else {
jsonElement.jsonObject.get(it) ?: throw AssertionError("\"$node\" element not found on json response")
}
}
return jsonElement
}
private val responseJsonElement: JsonElement
get() = Json.parse(KtorServerContext.defaultServer.call?.response?.content ?: fail("The response isn't valid JSON"))
}

View File

@@ -1,116 +0,0 @@
package feature
import com.google.gson.Gson
import com.google.gson.JsonParser
import cucumber.api.java8.En
import fr.dcproject.entity.Citizen
import fr.dcproject.entity.User
import fr.postgresjson.connexion.Requester
import fr.postgresjson.migration.Migrations
import io.cucumber.datatable.DataTable
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.setBody
import org.joda.time.DateTime
import org.junit.jupiter.api.Assertions.assertEquals
import org.koin.test.KoinTest
import org.koin.test.inject
import org.opentest4j.AssertionFailedError
import java.util.*
import kotlin.random.Random
import kotlin.test.assertTrue
import kotlin.test.asserter
import feature.Context.Companion.current as currentContext
class Request: En, KoinTest {
private val migrations: Migrations by inject()
private val requester: Requester by inject()
init {
When("I have citizen:") { body: DataTable ->
val user = User(username = "jaque_${Random.nextInt(0, 10000)}", plainPassword = "azerty")
val test: TestApplicationEngine.() -> Unit = {
requester
.getFunction("insert_user")
.selectOne(user)
val data = body.asMap<String, String>(String::class.java, String::class.java)
val citizen = Citizen(
id = UUID.fromString(data["id"]),
name = Citizen.Name(data["firstName"], data["lastName"]),
birthday = DateTime.now(),
user = user
)
requester
.getFunction("upsert_citizen")
.selectOne(citizen)
}
currentContext.engine.test()
}
When("I send a {string} request to {string} with body:") { method: String, uri: String, body: String ->
val test: TestApplicationEngine.() -> Unit = {
currentContext.call = handleRequest {
applyConfigurations()
addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
this.method = HttpMethod.parse(method)
this.uri = uri
setBody(body)
}
}
currentContext.engine.test()
}
When("I send a {string} request to {string}") { method: String, uri: String ->
val test: TestApplicationEngine.() -> Unit = {
currentContext.call = handleRequest {
applyConfigurations()
addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
this.method = HttpMethod.parse(method.toUpperCase())
this.uri = uri
}
}
currentContext.engine.test()
}
Then("the response status code should be {int}") { statusCode: Int ->
val call: TestApplicationCall = currentContext.call ?: throw AssertionFailedError("No call", statusCode, null)
with(call) {
assertEquals(HttpStatusCode.fromValue(statusCode), response.status(), response.content)
}
}
And("the response should contain:") { expected: DataTable ->
val call: TestApplicationCall = currentContext.call ?: throw AssertionFailedError("No call")
val p = call.response
val response = Gson().fromJson<List<Map<String, String>>>(p.content, List::class.java)
expected.asMap<String, String>(String::class.java, String::class.java).forEach { (key, value) ->
response.forEach {
if (it.containsKey(key)) {
assertEquals(it[key], value)
return@And
}
}
asserter.fail("The response not contain $key field")
}
}
And("the response should contain object:") { expected: DataTable ->
val call: TestApplicationCall = currentContext.call ?: throw AssertionFailedError("No call")
val p = call.response
val response = JsonParser().parse(p.content).getAsJsonObject()
expected.asMap<String, String>(String::class.java, String::class.java).forEach { (key, valueExpected) ->
assertTrue(response.has(key))
assertEquals(valueExpected, response.get(key).asString)
}
}
}
}

View File

@@ -1,11 +1,11 @@
Feature: articles routes Feature: articles routes
Scenario: The route for get articles must response a 200 Scenario: The route for get articles must response a 200
When I send a "GET" request to "/articles" When I send a GET request to "/articles"
Then the response status code should be 200 Then the response status code should be 200
Scenario: The route for get one article must response a 200 and return article Scenario: The route for get one article must response a 200 and return article
When I send a "GET" request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b" When I send a GET request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b"
Then the response status code should be 200 Then the response status code should be 200
And the response should contain object: And the response should contain object:
| id | 9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b | | id | 9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b |
@@ -15,7 +15,8 @@ Feature: articles routes
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "POST" request to "/articles" with body: And I am authenticated as an user
When I send a POST request to "/articles" with body:
""" """
{ {
"version_id": "09c418b6-63ba-448b-b38b-502b41cd500e", "version_id": "09c418b6-63ba-448b-b38b-502b41cd500e",

View File

@@ -1,11 +1,11 @@
Feature: citizens routes Feature: citizens routes
Scenario: The route for get citizens must response a 200 Scenario: The route for get citizens must response a 200
When I send a "GET" request to "/citizens" When I send a GET request to "/citizens"
Then the response status code should be 200 Then the response status code should be 200
Scenario: The route for get one citizen must response a 200 and return citizen Scenario: The route for get one citizen must response a 200 and return citizen
When I send a "GET" request to "/citizens/6434f4f9-f570-f22a-c134-8668350651ff" When I send a GET request to "/citizens/6434f4f9-f570-f22a-c134-8668350651ff"
Then the response status code should be 200 Then the response status code should be 200
And the response should contain object: And the response should contain object:
| id | 6434f4f9-f570-f22a-c134-8668350651ff | | id | 6434f4f9-f570-f22a-c134-8668350651ff |

View File

@@ -1,11 +1,11 @@
Feature: constitution routes Feature: constitution routes
Scenario: The route for get constitutions must response a 200 Scenario: The route for get constitutions must response a 200
When I send a "GET" request to "/constitutions" When I send a GET request to "/constitutions"
Then the response status code should be 200 Then the response status code should be 200
Scenario: The route for get one constitution must response a 200 and return constitution Scenario: The route for get one constitution must response a 200 and return constitution
When I send a "GET" request to "/constitutions/0ca489a6-ef68-8bd5-2355-5793d4b3d66c" When I send a GET request to "/constitutions/0ca489a6-ef68-8bd5-2355-5793d4b3d66c"
Then the response status code should be 200 Then the response status code should be 200
And the response should contain object: And the response should contain object:
| id | 0ca489a6-ef68-8bd5-2355-5793d4b3d66c | | id | 0ca489a6-ef68-8bd5-2355-5793d4b3d66c |
@@ -15,7 +15,7 @@ Feature: constitution routes
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "POST" request to "/constitutions" with body: When I send a POST request to "/constitutions" with body:
""" """
{ {
"version_id":"15814bb6-8d90-4c6a-a456-c3939a8ec75e", "version_id":"15814bb6-8d90-4c6a-a456-c3939a8ec75e",

View File

@@ -6,7 +6,7 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "POST" request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b/follow" When I send a POST request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b/follow"
Then the response status code should be 201 Then the response status code should be 201
Scenario: The route for get follows of articles must response a 200 and return objects Scenario: The route for get follows of articles must response a 200 and return objects
@@ -14,7 +14,7 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "GET" request to "/citizens/64b7b379-2298-43ec-b428-ba134930cabd/follows/articles" When I send a GET request to "/citizens/64b7b379-2298-43ec-b428-ba134930cabd/follows/articles"
Then the response status code should be 200 Then the response status code should be 200
And the response should contain object: And the response should contain object:
| current_page | 1 | | current_page | 1 |
@@ -25,7 +25,7 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "DELETE" request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b/follow" When I send a DELETE request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b/follow"
Then the response status code should be 204 Then the response status code should be 204
# Constitution # Constitution
@@ -34,7 +34,7 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "POST" request to "/constitutions/72aa1ee1-4963-eb44-c9e0-5ce6e0f18f00/follow" When I send a POST request to "/constitutions/72aa1ee1-4963-eb44-c9e0-5ce6e0f18f00/follow"
Then the response status code should be 201 Then the response status code should be 201
Scenario: The route for get follows of constitutions must response a 200 and return objects Scenario: The route for get follows of constitutions must response a 200 and return objects
@@ -42,7 +42,7 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "GET" request to "/citizens/64b7b379-2298-43ec-b428-ba134930cabd/follows/constitutions" When I send a GET request to "/citizens/64b7b379-2298-43ec-b428-ba134930cabd/follows/constitutions"
Then the response status code should be 200 Then the response status code should be 200
And the response should contain object: And the response should contain object:
| current_page | 1 | | current_page | 1 |
@@ -53,5 +53,5 @@ Feature: follow Article and Constitution
| id | 64b7b379-2298-43ec-b428-ba134930cabd | | id | 64b7b379-2298-43ec-b428-ba134930cabd |
| firstName | Jaque | | firstName | Jaque |
| lastName | Dupuis | | lastName | Dupuis |
When I send a "DELETE" request to "/constitutions/72aa1ee1-4963-eb44-c9e0-5ce6e0f18f00/follow" When I send a DELETE request to "/constitutions/72aa1ee1-4963-eb44-c9e0-5ce6e0f18f00/follow"
Then the response status code should be 204 Then the response status code should be 204