diff --git a/build.gradle.kts b/build.gradle.kts index 5aac87b..18fc380 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(hlaeja.springboot.starter.actuator) implementation(hlaeja.springboot.starter.r2dbc) implementation(hlaeja.springboot.starter.security) + implementation(hlaeja.springboot.starter.validation) implementation(hlaeja.springboot.starter.webflux) runtimeOnly(hlaeja.postgresql) @@ -29,6 +30,15 @@ dependencies { testImplementation(hlaeja.springboot.starter.test) testRuntimeOnly(hlaeja.junit.platform.launcher) + + integrationTestImplementation(hlaeja.assertj.core) + integrationTestImplementation(hlaeja.library.hlaeja.test) + integrationTestImplementation(hlaeja.projectreactor.reactor.test) + integrationTestImplementation(hlaeja.kotlin.test.junit5) + integrationTestImplementation(hlaeja.kotlinx.coroutines.test) + integrationTestImplementation(hlaeja.springboot.starter.test) + + integrationTestRuntimeOnly(hlaeja.junit.platform.launcher) } group = "ltd.hlaeja" diff --git a/http/account.http b/http/account.http index 955bac8..776278b 100644 --- a/http/account.http +++ b/http/account.http @@ -15,15 +15,6 @@ Content-Type: application/json ] } -### Get accounts -GET {{hostname}}/accounts - -### Get accounts by page -GET {{hostname}}/accounts/page-1 - -### Get accounts by page and size -GET {{hostname}}/accounts/page-1/show-5 - ### update user all information PUT {{hostname}}/account-00000000-0000-7000-0000-000000000002 Content-Type: application/json diff --git a/http/accounts.http b/http/accounts.http new file mode 100644 index 0000000..ae3593d --- /dev/null +++ b/http/accounts.http @@ -0,0 +1,8 @@ +### Get accounts +GET {{hostname}}/accounts + +### Get accounts by page +GET {{hostname}}/accounts/page-1 + +### Get accounts by page and size +GET {{hostname}}/accounts/page-1/show-1 diff --git a/src/test/kotlin/ltd/hlaeja/ApplicationTests.kt b/src/integration-test/kotlin/ltd/hlaeja/ApplicationTests.kt similarity index 100% rename from src/test/kotlin/ltd/hlaeja/ApplicationTests.kt rename to src/integration-test/kotlin/ltd/hlaeja/ApplicationTests.kt diff --git a/src/integration-test/kotlin/ltd/hlaeja/controller/AccountsEndpoint.kt b/src/integration-test/kotlin/ltd/hlaeja/controller/AccountsEndpoint.kt new file mode 100644 index 0000000..deb7aa1 --- /dev/null +++ b/src/integration-test/kotlin/ltd/hlaeja/controller/AccountsEndpoint.kt @@ -0,0 +1,118 @@ +package ltd.hlaeja.controller + +import java.util.UUID +import ltd.hlaeja.library.accountRegistry.Account +import ltd.hlaeja.test.container.PostgresContainer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.reactive.server.expectBody + +@PostgresContainer +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ExtendWith(SoftAssertionsExtension::class) +class AccountsEndpoint { + + @LocalServerPort + var port: Int = 0 + + lateinit var webClient: WebTestClient + + @BeforeEach + fun setup() { + webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build() + } + + @Test + fun `get accounts`() { + // when + val result = webClient.get().uri("/accounts").exchange() + + // then + result.expectStatus().isOk() + .expectBody>() + .consumeWith { + assertThat(it.responseBody?.size).isEqualTo(3) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "1,3", + "2,0", + ] + ) + fun `get accounts with pages`(page: Int, expected: Int) { + // when + val result = webClient.get().uri("/accounts/page-$page").exchange() + + // then + result.expectStatus().isOk() + .expectBody>() + .consumeWith { + assertThat(it.responseBody?.size).isEqualTo(expected) + } + } + + @Test + fun `get accounts with bad pages`() { + // when + val result = webClient.get().uri("/accounts/page-0").exchange() + + // then + result.expectStatus().isBadRequest + } + + @ParameterizedTest + @CsvSource( + value = [ + "1,2,2", + "2,2,1", + "3,2,0", + "1,5,3", + "2,5,0", + ] + ) + fun `get accounts with pages and size to show`(page: Int, show: Int, expected: Int) { + // when + val result = webClient.get().uri("/accounts/page-$page/show-$show").exchange() + + // then + result.expectStatus().isOk() + .expectBody>() + .consumeWith { + assertThat(it.responseBody?.size).isEqualTo(expected) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "1,0", + "0,1", + "0,0", + "1,-1", + "-1,1", + "-1,-1", + ] + ) + fun `get accounts with bad pages or bad size to show`(page: Int, show: Int) { + // when + val result = webClient.get().uri("/accounts/page-$page/show-$show").exchange() + + // then + result.expectStatus().isBadRequest + } +} diff --git a/src/integration-test/resources/application.yml b/src/integration-test/resources/application.yml new file mode 100644 index 0000000..3eb9709 --- /dev/null +++ b/src/integration-test/resources/application.yml @@ -0,0 +1,12 @@ +jwt: + private-key: cert/valid-private-key.pem + +spring: + r2dbc: + url: r2dbc:pool:postgresql://localhost:5432/test + username: test + password: test + +test: + postgres: + init-script: postgres/schema.sql diff --git a/src/test/resources/cert/valid-private-key.pem b/src/integration-test/resources/cert/valid-private-key.pem similarity index 100% rename from src/test/resources/cert/valid-private-key.pem rename to src/integration-test/resources/cert/valid-private-key.pem diff --git a/src/integration-test/resources/postgres/schema.sql b/src/integration-test/resources/postgres/schema.sql new file mode 100644 index 0000000..be82893 --- /dev/null +++ b/src/integration-test/resources/postgres/schema.sql @@ -0,0 +1,80 @@ +-- FUNCTION: public.gen_uuid_v7(timestamp with time zone) +CREATE OR REPLACE FUNCTION public.gen_uuid_v7(p_timestamp timestamp with time zone) + RETURNS uuid + LANGUAGE 'sql' + COST 100 + VOLATILE PARALLEL UNSAFE +AS +$BODY$ +-- Replace the first 48 bits of a uuid v4 with the provided timestamp (in milliseconds) since 1970-01-01 UTC, and set the version to 7 +SELECT encode(set_bit(set_bit(overlay(uuid_send(gen_random_uuid()) PLACING substring(int8send((extract(EPOCH FROM p_timestamp) * 1000):: BIGINT) FROM 3) FROM 1 FOR 6), 52, 1), 53, 1), 'hex') ::uuid; +$BODY$; + +-- FUNCTION: public.gen_uuid_v7() +CREATE OR REPLACE FUNCTION public.gen_uuid_v7() + RETURNS uuid + LANGUAGE 'sql' + COST 100 + VOLATILE PARALLEL UNSAFE +AS +$BODY$ +SELECT gen_uuid_v7(clock_timestamp()); +$BODY$; + +-- Table: public.accounts +CREATE TABLE IF NOT EXISTS public.accounts +( + id UUID DEFAULT gen_uuid_v7(), + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + enabled boolean NOT NULL DEFAULT true, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + roles VARCHAR(255) NOT NULL, + CONSTRAINT pk_contact_types PRIMARY KEY (id) +); + +-- Index: idx_accounts_username +CREATE INDEX IF NOT EXISTS idx_accounts_username + ON public.accounts USING btree (username COLLATE pg_catalog."default" ASC NULLS LAST) + TABLESPACE pg_default; + +-- Table: public.accounts_audit +CREATE TABLE IF NOT EXISTS public.accounts_audit +( + id uuid NOT NULL, + timestamp timestamp with time zone NOT NULL, + enabled boolean NOT NULL, + username VARCHAR(50) NOT NULL, + password VARCHAR(255) NOT NULL, + roles VARCHAR(255) NOT NULL, + CONSTRAINT pk_accounts_audit PRIMARY KEY (id, timestamp) +) TABLESPACE pg_default; + +-- FUNCTION: public.accounts_audit() +CREATE OR REPLACE FUNCTION public.accounts_audit() + RETURNS trigger + LANGUAGE 'plpgsql' + COST 100 + VOLATILE NOT LEAKPROOF +AS +$BODY$ +BEGIN + INSERT INTO accounts_audit (id, timestamp, enabled, username, password, roles) + VALUES (NEW.id, NEW.updated_at, NEW.enabled, NEW.username, NEW.password, NEW.roles); + RETURN NULL; -- result is ignored since this is an AFTER trigger +END; +$BODY$; + +-- Trigger: accounts_audit_trigger +CREATE OR REPLACE TRIGGER accounts_audit_trigger + AFTER INSERT OR UPDATE + ON public.accounts + FOR EACH ROW +EXECUTE FUNCTION public.accounts_audit(); + +-- Test data +insert into public.accounts (id, created_at, updated_at, enabled, username, password, roles) +values ('00000000-0000-7000-0000-000000000001'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'admin', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_ADMIN'), + ('00000000-0000-7000-0000-000000000002'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', true, 'user', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER'), + ('00000000-0000-7000-0000-000000000003'::uuid, '2000-01-01 00:00:00.000001 +00:00', '2000-01-01 00:00:01.000001 +00:00', false, 'disabled', '$2a$12$KoXBoLOANMK11J4xeJHPA.Sy0FG.m8KWk7P4XFsMO.ZbFmFI2DckK', 'ROLE_USER'); diff --git a/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt index 387ff59..9b1bbd0 100644 --- a/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt +++ b/src/main/kotlin/ltd/hlaeja/controller/AccountController.kt @@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException -import reactor.core.publisher.Flux import reactor.core.publisher.Mono @RestController @@ -24,10 +23,6 @@ class AccountController( private val accountService: AccountService, private val passwordEncoder: PasswordEncoder, ) { - companion object { - const val DEFAULT_PAGE: Int = 1 - const val DEFAULT_SIZE: Int = 25 - } @GetMapping("/account-{uuid}") fun getAccount( @@ -53,26 +48,6 @@ class AccountController( ): Mono = accountService.addAccount(request.toAccountEntity(passwordEncoder)) .map { it.toAccountResponse() } - @GetMapping("/accounts") - fun getDefaultAccounts(): Flux = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE) - - @GetMapping("/accounts/page-{page}") - fun getAccountsPage( - @PathVariable page: Int, - ): Flux = getAccounts(page, DEFAULT_SIZE) - - @GetMapping("/accounts/page-{page}/show-{size}") - fun getAccountsPageSize( - @PathVariable page: Int, - @PathVariable size: Int, - ): Flux = getAccounts(page, size) - - private fun getAccounts( - page: Int, - size: Int, - ): Flux = accountService.getAccounts(page, size) - .map { it.toAccountResponse() } - private fun hasChange( user: AccountEntity, update: AccountEntity, diff --git a/src/main/kotlin/ltd/hlaeja/controller/AccountsController.kt b/src/main/kotlin/ltd/hlaeja/controller/AccountsController.kt new file mode 100644 index 0000000..87d3bc9 --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/controller/AccountsController.kt @@ -0,0 +1,42 @@ +package ltd.hlaeja.controller + +import jakarta.validation.constraints.Min +import ltd.hlaeja.library.accountRegistry.Account +import ltd.hlaeja.service.AccountService +import ltd.hlaeja.util.toAccountResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Flux + +@RestController +@RequestMapping("/accounts") +class AccountsController( + private val accountService: AccountService, +) { + companion object { + const val DEFAULT_PAGE: Int = 1 + const val DEFAULT_SIZE: Int = 25 + } + + @GetMapping + fun getDefaultAccounts(): Flux = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE) + + @GetMapping("/page-{page}") + fun getAccountsPage( + @PathVariable @Min(1) page: Int, + ): Flux = getAccounts(page, DEFAULT_SIZE) + + @GetMapping("/page-{page}/show-{size}") + fun getAccountsPageSize( + @PathVariable @Min(1) page: Int, + @PathVariable @Min(1) size: Int, + ): Flux = getAccounts(page, size) + + private fun getAccounts( + page: Int, + size: Int, + ): Flux = accountService.getAccounts(page, size) + .map { it.toAccountResponse() } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml deleted file mode 100644 index 32c88c7..0000000 --- a/src/test/resources/application.yml +++ /dev/null @@ -1,8 +0,0 @@ -jwt: - private-key: cert/valid-private-key.pem - -spring: - r2dbc: - url: r2dbc:postgresql://placeholder - username: placeholder - password: placeholder