diff --git a/service-chats/pom.xml b/service-chats/pom.xml index 9ecd039..16e10cf 100644 --- a/service-chats/pom.xml +++ b/service-chats/pom.xml @@ -16,8 +16,6 @@ io.quarkus.platform 2.4.1.Final 3.0.0-M5 - 2.7.9.2 - 4.0.183 @@ -87,18 +85,10 @@ rest-assured test - - it.tdlight - tdlight-java - - - it.tdlight - tdlight-natives-linux-amd64 - - - org.apache.commons - commons-lang3 + org.assertj + assertj-core + test diff --git a/service-chats/src/main/java/io/volvox/chats/Chat.java b/service-chats/src/main/java/io/volvox/chats/Chat.java index 650fe51..d07174f 100644 --- a/service-chats/src/main/java/io/volvox/chats/Chat.java +++ b/service-chats/src/main/java/io/volvox/chats/Chat.java @@ -1,23 +1,58 @@ package io.volvox.chats; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import java.util.StringJoiner; +import javax.persistence.Cacheable; +import javax.persistence.Column; import javax.persistence.Entity; +import javax.persistence.Enumerated; import javax.persistence.Id; +import javax.validation.constraints.Max; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Positive; +import org.hibernate.validator.constraints.Length; @Entity +@Cacheable public class Chat extends PanacheEntityBase { - @Id - public String id; + @Id + @Positive(message = "id is not positive") + @Max(message = "id is too big", value = ChatId.MASK) + @Column(nullable = false, unique = true) + @JsonSerialize(using = ChatIdJsonSerializer.class) + @JsonDeserialize(using = ChatIdJsonDeserializer.class) + public Long id; + @Column(length = 128) public String name; + @Length(message = "Username length is not valid", min = 5) + @Column(length = 48) + @Length(message = "Username must not be an empty string", min = 1, max = 12 + 32) + @Pattern(message = "Username contains invalid characters", regexp = "^(?:[a-zA-Z\\d][_]?)+$") + @Pattern(message = "Username is not valid", regexp = "^(?:translation_|mv_)?[a-zA-Z]([a-zA-Z_\\d]){1,30}[a-zA-Z\\d]$") public String username; + @Enumerated public Status status; + @JsonIgnore public ChatId getChatId() { - return new ChatId(id); + return ChatId.fromLong(id); } + @JsonIgnore public void setChatId(ChatId id) { - this.id = id.toString(); + this.id = id.toLong(); + } + + @Override public String toString() { + return new StringJoiner(", ", Chat.class.getSimpleName() + "[", "]") + .add("id=" + id) + .add("name='" + name + "'") + .add("username='" + username + "'") + .add("status=" + status) + .toString(); } } diff --git a/service-chats/src/main/java/io/volvox/chats/ChatId.java b/service-chats/src/main/java/io/volvox/chats/ChatId.java index 9141d4d..52a9bf2 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatId.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatId.java @@ -1,35 +1,116 @@ package io.volvox.chats; -public record ChatId(Type type, long id) { - ChatId(String id) { - this(getType(id), getIdLong(id)); +public record ChatId(Type type, long subId) { + + public static final int SUB_ID_MASK_BYTES = 52; + public static final int TYPE_MASK_BYTES = 2; + + public static final long SUB_ID_MASK = 0b001111111111111111111111111111111111111111111111111111L; + public static final long TYPE_MASK = 0b11L << SUB_ID_MASK_BYTES; + public static final long MASK = SUB_ID_MASK | TYPE_MASK; + public static final int TYPE_PRIVATE_INT = 0b00; + public static final int TYPE_BASIC_INT = 0b01; + public static final int TYPE_SUPER_INT = 0b10; + public static final int TYPE_SECRET_INT = 0b11; + public static final long TYPE_PRIVATE_LONG = 0; + public static final long TYPE_BASIC_LONG = 0b01L << SUB_ID_MASK_BYTES & TYPE_MASK; + public static final long TYPE_SUPER_LONG = 0b10L << SUB_ID_MASK_BYTES & TYPE_MASK; + public static final long TYPE_SECRET_LONG = 0b11L << SUB_ID_MASK_BYTES & TYPE_MASK; + + public ChatId { + if ((subId & SUB_ID_MASK) != subId) { + throw new IllegalArgumentException("subId is too big"); + } } - private static Type getType(String id) { - return switch (id.charAt(0)) { - case 's' -> Type.SUPER; - case 'b' -> Type.BASIC; - case 'u' -> Type.PRIVATE; - default -> throw new IllegalArgumentException(); + public static ChatId fromLong(long id) { + return new ChatId(getType(id), getIdLong(id)); + } + + private static Type getType(long id) { + return switch ((int) ((id & TYPE_MASK) >> SUB_ID_MASK_BYTES)) { + case TYPE_SUPER_INT -> Type.SUPER; + case TYPE_BASIC_INT -> Type.BASIC; + case TYPE_PRIVATE_INT -> Type.PRIVATE; + case TYPE_SECRET_INT -> Type.SECRET; + default -> throw new IllegalArgumentException("Invalid id type: " + id); }; } - private static long getIdLong(String id) { - return Long.parseUnsignedLong(id.substring(1)); + private static long getIdLong(long id) { + return id & SUB_ID_MASK; + } + + public long toLong() { + return switch (type) { + case SUPER -> TYPE_SUPER_LONG; + case BASIC -> TYPE_BASIC_LONG; + case PRIVATE -> TYPE_PRIVATE_LONG; + case SECRET -> TYPE_SECRET_LONG; + } | (subId & SUB_ID_MASK); } public enum Type { + PRIVATE, BASIC, SUPER, - PRIVATE + SECRET } @Override public String toString() { - return switch (type) { + return toString(this); + } + + public static String toString(ChatId chatId) { + return Long.toUnsignedString(chatId.subId) + "-" + switch (chatId.type) { case SUPER -> 's'; case BASIC -> 'b'; case PRIVATE -> 'u'; - } + Long.toUnsignedString(id); + case SECRET -> 'd'; + }; + } + + public static String toString(long chatId) { + return Long.toUnsignedString(getIdLong(chatId)) + "-" + switch (getType(chatId)) { + case SUPER -> 's'; + case BASIC -> 'b'; + case PRIVATE -> 'u'; + case SECRET -> 'd'; + }; + } + + public static ChatId fromString(String chatId) { + var parts = chatId.split("-", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Malformed chat id"); + } + if (parts[1].length() != 1) { + throw new IllegalArgumentException("Chat type is too long"); + } + return new ChatId(switch(parts[1].charAt(0)) { + case 's' -> Type.SUPER; + case 'b' -> Type.BASIC; + case 'u' -> Type.PRIVATE; + case 'd' -> Type.SECRET; + default -> throw new IllegalStateException("Unexpected value: " + parts[1].charAt(0)); + }, Long.parseUnsignedLong(parts[0]) & SUB_ID_MASK); + } + + public static Long stringToLong(String chatId) { + var parts = chatId.split("-", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Malformed chat id"); + } + if (parts[1].length() != 1) { + throw new IllegalArgumentException("Chat type is too long"); + } + return switch(parts[1].charAt(0)) { + case 's' -> TYPE_SUPER_LONG; + case 'b' -> TYPE_BASIC_LONG; + case 'u' -> TYPE_PRIVATE_LONG; + case 'd' -> TYPE_SECRET_LONG; + default -> throw new IllegalStateException("Unexpected value: " + parts[1].charAt(0)); + } | (Long.parseUnsignedLong(parts[0]) & SUB_ID_MASK); } } diff --git a/service-chats/src/main/java/io/volvox/chats/ChatIdJsonDeserializer.java b/service-chats/src/main/java/io/volvox/chats/ChatIdJsonDeserializer.java new file mode 100644 index 0000000..3cb146d --- /dev/null +++ b/service-chats/src/main/java/io/volvox/chats/ChatIdJsonDeserializer.java @@ -0,0 +1,15 @@ +package io.volvox.chats; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import java.io.IOException; + +public class ChatIdJsonDeserializer extends JsonDeserializer { + + @Override + public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + var id = p.readValueAs(String.class); + return ChatId.stringToLong(id); + } +} diff --git a/service-chats/src/main/java/io/volvox/chats/ChatIdJsonSerializer.java b/service-chats/src/main/java/io/volvox/chats/ChatIdJsonSerializer.java new file mode 100644 index 0000000..a07cd43 --- /dev/null +++ b/service-chats/src/main/java/io/volvox/chats/ChatIdJsonSerializer.java @@ -0,0 +1,14 @@ +package io.volvox.chats; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; + +public class ChatIdJsonSerializer extends JsonSerializer { + + @Override + public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(ChatId.toString(value)); + } +} diff --git a/service-chats/src/main/java/io/volvox/chats/ChatRepository.java b/service-chats/src/main/java/io/volvox/chats/ChatRepository.java index 69d6f33..14dd399 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatRepository.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatRepository.java @@ -14,7 +14,7 @@ import org.hibernate.annotations.NamedQuery; @NamedQuery(name = "Chat.updateStatusById", query = "update Chat p set p.status = :status where p.id = :id"), @NamedQuery(name = "Chat.deleteById", query = "delete from Chat p where p.id = ?1") }) -public class ChatRepository implements PanacheRepositoryBase { +public class ChatRepository implements PanacheRepositoryBase { public Uni findByUsername(String username) { return find("#Chat.getByUsername", username).firstResult(); diff --git a/service-chats/src/main/java/io/volvox/chats/ChatResource.java b/service-chats/src/main/java/io/volvox/chats/ChatResource.java index c9f5469..4cd1454 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatResource.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatResource.java @@ -1,11 +1,10 @@ package io.volvox.chats; -import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.quarkus.hibernate.reactive.panache.Panache; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import java.net.URI; import javax.inject.Inject; -import javax.transaction.Transactional; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; @@ -33,24 +32,21 @@ public class ChatResource { @GET @Path("/{id}") - public Uni get(@PathParam("id") String id) { + public Uni get(@PathParam("id") Long id) { return chatRepository.findById(id); } @POST - @Transactional public Uni create(Chat chat) { - return chatRepository.persist(chat) - // Return success - .replaceWith(() -> Response.created(URI.create("/api/" + chat.id)).build()); + return Panache.withTransaction(() -> chatRepository.persist(chat)) + .onItem().transform(inserted -> Response.created(URI.create("/api/" + chat.id)).build()); } @PUT @Path("/{id}") - @Transactional - public Uni update(@PathParam("id") String id, Chat chat) { + public Uni update(@PathParam("id") Long id, Chat chat) { // Find chat by id - return chatRepository.findById(id) + return Panache.withTransaction(() -> chatRepository.findById(id) .flatMap(entity -> { if (entity == null) { // Persist the chat if not found @@ -61,16 +57,15 @@ public class ChatResource { // Return the updated item return Uni.createFrom().item(entity); } - }); + })); } @DELETE @Path("/{id}") - @Transactional - public Uni delete(@PathParam("id") String id) { - return chatRepository.findById(id) + public Uni delete(@PathParam("id") Long id) { + return Panache.withTransaction(() -> chatRepository.findById(id) .onItem().ifNull().failWith(NotFoundException::new) - .flatMap(PanacheEntityBase::delete); + .flatMap(chatRepository::delete)); } @GET diff --git a/service-chats/src/main/java/io/volvox/chats/ChatsService.java b/service-chats/src/main/java/io/volvox/chats/ChatsService.java index a00875d..330a32b 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatsService.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatsService.java @@ -21,7 +21,7 @@ public class ChatsService { } @ConsumeEvent(value = "chats.get") - public void get(Message msg) { + public void get(Message msg) { chatResource.get(msg.body()).subscribe().with(msg::reply); } diff --git a/service-chats/src/main/java/io/volvox/chats/Status.java b/service-chats/src/main/java/io/volvox/chats/Status.java index b95809e..f5220b4 100644 --- a/service-chats/src/main/java/io/volvox/chats/Status.java +++ b/service-chats/src/main/java/io/volvox/chats/Status.java @@ -1,7 +1,6 @@ package io.volvox.chats; public enum Status { - ALIVE, - DELETED, - UNKNOWN + DEAD, + ALIVE } diff --git a/service-chats/src/main/resources/application.properties b/service-chats/src/main/resources/application.properties index e69de29..7b73292 100644 --- a/service-chats/src/main/resources/application.properties +++ b/service-chats/src/main/resources/application.properties @@ -0,0 +1,10 @@ +%prod.quarkus.datasource.db-kind=postgresql +%prod.quarkus.datasource.username=quarkus_test +%prod.quarkus.datasource.password=quarkus_test + +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.log.sql=true +quarkus.hibernate-orm.sql-load-script=import.sql + +# Reactive config +%prod.quarkus.datasource.reactive.url=vertx-reactive:postgresql://localhost/quarkus_test diff --git a/service-chats/src/main/resources/import.sql b/service-chats/src/main/resources/import.sql new file mode 100644 index 0000000..fb6d842 --- /dev/null +++ b/service-chats/src/main/resources/import.sql @@ -0,0 +1,4 @@ +INSERT INTO Chat(id, name, username, status) VALUES (9007199256673076, 'My Supergroup', 'mysupergroup', 1); +INSERT INTO Chat(id, name, username, status) VALUES (777000, 'Telegram', 'telegram', null); +INSERT INTO Chat(id, name, username, status) VALUES (4503599627464345, 'School group', null, 1); +INSERT INTO Chat(id, name, username, status) VALUES (4503599627382355, 'Old school group', null, 0); diff --git a/service-chats/src/test/java/io/volvox/chats/ChatIdTest.java b/service-chats/src/test/java/io/volvox/chats/ChatIdTest.java new file mode 100644 index 0000000..e387c48 --- /dev/null +++ b/service-chats/src/test/java/io/volvox/chats/ChatIdTest.java @@ -0,0 +1,70 @@ +package io.volvox.chats; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.volvox.chats.ChatId.Type; +import org.junit.jupiter.api.Test; + +public class ChatIdTest { + + @Test + public void newChatId() { + assertEquals(Type.BASIC, new ChatId(Type.BASIC, 10).type()); + assertEquals(Type.PRIVATE, new ChatId(Type.PRIVATE, 10).type()); + assertEquals(Type.SECRET, new ChatId(Type.SECRET, 10).type()); + assertEquals(Type.SUPER, new ChatId(Type.SUPER, 10).type()); + + assertEquals(1, new ChatId(Type.BASIC, 1).subId()); + assertEquals(2, new ChatId(Type.PRIVATE, 2).subId()); + assertEquals(3, new ChatId(Type.SECRET, 3).subId()); + assertEquals(4, new ChatId(Type.SUPER, 4).subId()); + } + + @Test + public void toLong() { + assertEquals(777000, new ChatId(Type.PRIVATE, 777000).toLong()); + assertEquals(0b01L << 52 | 777000, new ChatId(Type.BASIC, 777000).toLong()); + assertEquals(0b10L << 52 | 777000, new ChatId(Type.SUPER, 777000).toLong()); + assertEquals(0b11L << 52 | 777000, new ChatId(Type.SECRET, 777000).toLong()); + } + + @Test + public void fromLong() { + assertEquals(new ChatId(Type.PRIVATE, 777000), ChatId.fromLong(777000)); + assertEquals(new ChatId(Type.BASIC, 777000), ChatId.fromLong(0b01L << 52 | 777000)); + assertEquals(new ChatId(Type.SUPER, 777000), ChatId.fromLong(0b10L << 52 | 777000)); + assertEquals(new ChatId(Type.SECRET, 777000), ChatId.fromLong(0b11L << 52 | 777000)); + } + + @Test + public void fromString() { + assertEquals(new ChatId(Type.PRIVATE, 777000), ChatId.fromString("777000-u")); + assertEquals(new ChatId(Type.BASIC, 777000), ChatId.fromString("777000-b")); + assertEquals(new ChatId(Type.SUPER, 777000), ChatId.fromString("777000-s")); + assertEquals(new ChatId(Type.SECRET, 777000), ChatId.fromString("777000-d")); + } + + @Test + public void testToString() { + assertEquals("777000-u", new ChatId(Type.PRIVATE, 777000).toString()); + assertEquals("777000-b", new ChatId(Type.BASIC, 777000).toString()); + assertEquals("777000-s", new ChatId(Type.SUPER, 777000).toString()); + assertEquals("777000-d", new ChatId(Type.SECRET, 777000).toString()); + } + + @Test + public void longToString() { + assertEquals("777000-u", ChatId.toString(777000)); + assertEquals("777000-b", ChatId.toString(0b01L << 52 | 777000)); + assertEquals("777000-s", ChatId.toString(0b10L << 52 | 777000)); + assertEquals("777000-d", ChatId.toString(0b11L << 52 | 777000)); + } + + @Test + public void stringToLong() { + assertEquals(777000, ChatId.stringToLong("777000-u")); + assertEquals(0b01L << 52 | 777000, ChatId.stringToLong("777000-b")); + assertEquals(0b10L << 52 | 777000, ChatId.stringToLong("777000-s")); + assertEquals(0b11L << 52 | 777000, ChatId.stringToLong("777000-d")); + } +} diff --git a/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java new file mode 100644 index 0000000..7d552c5 --- /dev/null +++ b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java @@ -0,0 +1,130 @@ +package io.volvox.chats; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.Matchers.emptyString; + +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; +import javax.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class ChatsEndpointTest { + + @Inject + ChatRepository chatRepository; + + @Test + public void testListAllChats() { + //List all, should have all 3 usernames the database has initially: + Response response = given() + .when() + .get("/api/chats") + .then() + .statusCode(200) + .contentType("application/json") + .extract().response(); + assertThat(response.jsonPath().getList("name")).containsExactlyInAnyOrder("My Supergroup", "Telegram", "School group", "Old school group"); + + // Update Telegram to Telegram Official + given() + .when() + .body("{\"name\" : \"Telegram Official\"}") + .contentType("application/json") + .put("/api/chats/777000") + .then() + .statusCode(200) + .body( + containsString("\"id\":"), + containsString("\"name\":\"Telegram Official\"")); + + //List all, Telegram Official should've replaced Telegram: + response = given() + .when() + .get("/api/chats") + .then() + .statusCode(200) + .contentType("application/json") + .extract().response(); + assertThat(response.jsonPath().getList("name")) + .containsExactlyInAnyOrder("My Supergroup", "Telegram Official", "School group", "Old school group"); + + //Delete Telegram: + given() + .when() + .delete("/api/chats/777000") + .then() + .statusCode(204); + + response = given() + .when() + .get("/api/chats") + .then() + .statusCode(200) + .contentType("application/json") + .extract().response(); + assertThat(response.jsonPath().getList("name")) + .containsExactlyInAnyOrder("My Supergroup", "School group", "Old school group"); + + //Create Telegram2: + given() + .when() + .body("{\"id\": \"777001-u\", \"name\" : \"Telegram2\"}") + .contentType("application/json") + .post("/api/chats") + .then() + .statusCode(201) + .body(emptyString()); + + //List all, Pineapple should be still missing now: + response = given() + .when() + .get("/api/chats") + .then() + .statusCode(200) + .extract().response(); + assertThat(response.jsonPath().getList("name")) + .containsExactlyInAnyOrder("My Supergroup", "School group", "Old school group", "Telegram2"); + } + + @Test + public void testEntityNotFoundForDelete() { + given() + .when() + .delete("/api/chats/777123") + .then() + .statusCode(404) + .body(emptyString()); + } + + @Test + public void testEntityNotFoundForUpdate() { + given() + .when() + .body("{\"id\": \"777234-u\", \"name\" : \"Juan\"}") + .contentType("application/json") + .put("/api/chats/777234") + .then() + .statusCode(200); + } + + @Test + public void testEntityWithoutIdForUpdate() { + given() + .when() + .body("{\"name\" : \"Juan\"}") + .contentType("application/json") + .put("/api/chats/777234") + .then() + .statusCode(500); + } + + @BeforeEach + public void tearDown(){ + Panache.withTransaction(() -> chatRepository.deleteById(777234L)).await().indefinitely(); + } +}