extract accounts and add integration test

- add end-to-end test AccountsEndpoint
- extract get accounts from account.http to accounts.http
- extract get accounts from AccountController to AccountsController
- move spring boot test file to integration test
- add schema.sql
- add dependencies for integration test
This commit is contained in:
2025-02-03 19:50:27 +01:00
parent 6e6ea72d54
commit a762a05c11
11 changed files with 270 additions and 42 deletions

View File

@@ -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<List<Account.Response>>()
.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<List<Account.Response>>()
.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<List<Account.Response>>()
.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
}
}

View File

@@ -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

View File

@@ -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');

View File

@@ -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<Account.Response> = accountService.addAccount(request.toAccountEntity(passwordEncoder))
.map { it.toAccountResponse() }
@GetMapping("/accounts")
fun getDefaultAccounts(): Flux<Account.Response> = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE)
@GetMapping("/accounts/page-{page}")
fun getAccountsPage(
@PathVariable page: Int,
): Flux<Account.Response> = getAccounts(page, DEFAULT_SIZE)
@GetMapping("/accounts/page-{page}/show-{size}")
fun getAccountsPageSize(
@PathVariable page: Int,
@PathVariable size: Int,
): Flux<Account.Response> = getAccounts(page, size)
private fun getAccounts(
page: Int,
size: Int,
): Flux<Account.Response> = accountService.getAccounts(page, size)
.map { it.toAccountResponse() }
private fun hasChange(
user: AccountEntity,
update: AccountEntity,

View File

@@ -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<Account.Response> = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE)
@GetMapping("/page-{page}")
fun getAccountsPage(
@PathVariable @Min(1) page: Int,
): Flux<Account.Response> = getAccounts(page, DEFAULT_SIZE)
@GetMapping("/page-{page}/show-{size}")
fun getAccountsPageSize(
@PathVariable @Min(1) page: Int,
@PathVariable @Min(1) size: Int,
): Flux<Account.Response> = getAccounts(page, size)
private fun getAccounts(
page: Int,
size: Int,
): Flux<Account.Response> = accountService.getAccounts(page, size)
.map { it.toAccountResponse() }
}

View File

@@ -1,8 +0,0 @@
jwt:
private-key: cert/valid-private-key.pem
spring:
r2dbc:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder