diff --git a/.junie/plans/db-package-best-practices.md b/.junie/plans/db-package-best-practices.md new file mode 100644 index 0000000..7a480e8 --- /dev/null +++ b/.junie/plans/db-package-best-practices.md @@ -0,0 +1,309 @@ +--- +sessionId: session-260925-080906-1d5c +--- + +# Findings + +### Correctness bugs (highest priority) +| # | Where | Problem | +|---|---|---| +| B1 | `BlacklistRepository.delete/add` | `getWordFromDb` loads the entity in its **own session** (which it also closes twice). The caller then calls `session.remove(bl)` in another session on that **detached** entity, which Hibernate rejects with `IllegalArgumentException`. The exception is swallowed by `BlacklistService`, which returns `warn`. | +| B2 | `GuildSettingRepository.setSettingValue` | Runs inside `JpaUtils.getWithSession`, which has **no transaction**, so `merge`/`remove` are never flushed or committed. Updating or resetting a setting is probably lost; only an IDENTITY `persist` happens to insert. | +| B3 | `Repository` | IDs are always `String`, but `OBlacklist.id` / `OGuildSetting.id` are `Integer`, so `deleteBy`/`findById` bind a type-mismatched parameter. `BlacklistRepository.findById` returns `null` outright. | +| B4 | `Repository.save`, `JpaUtils.updateWithTransaction` | Every non-constraint exception is swallowed after `logger.error(e)`, so callers think the write succeeded. `runWithTransaction` never rolls back on failure. | +| B5 | `JpaUtils.tryLoad` | Uses the deprecated `Session.load(Object, id)`. It closes a session it doesn't own and calls back into `GuildService` (a hidden circular dependency). | +| B6 | `MessageRepository.getMessageString` | NPE when the message isn't found. | +| B7 | `SessionManager.getSessionFactory` | Lazy initialization isn't thread-safe. The Hibernate factory is built **before** Flyway migrates, and the schema is never validated. | + +### Design / best-practice issues +- **Static service locator.** `JpaUtils.bind` / `bindGuildServiceLookup` (called from `App.java` l.119, l.138) hide dependencies and make repositories untestable. +- **Duplicated CRUD.** Five repositories copy-paste `findAll` / `findById` / `deleteBy`. They use the "lambda + external `ArrayList`" workaround because the helpers can't return values. +- **Duplicated JDBC config.** `SessionManager` and `DatabaseMigration` both build the URL, user and password from settings. +- **Layering.** Repositories take JDA types (`Member`, `Message`, `MessageChannel`), and `MessageRepository.log` carries a `//TODO: move to a service`. +- **Services create their own repositories** with `new`. `TaskService.persistTask`/`unpersistTask` even shadow the repository field it already holds. +- **Caches.** `GuildSettingService.reloadSettingsCache` clears the map and then refills it, so readers can see it empty. `getSettings()` and `GuildServiceProviderImpl.getServices()` leak mutable maps. Guild services are never evicted. +- **Logging mix.** The package uses both Log4j `LogManager` and SLF4J. +- **Entities.** No `equals`/`hashCode`. The field is named `OLoggings`. `OWebUser` is missing from `hibernate.cfg.xml`. +- **Hygiene.** Duplicated copyright headers and a broken `package ...;/*` line in `GuildRepository` and `GuildSettingRepository`. `GSettings` has a `//TODO: description`. +- **No tests** for the `db` package. + +# Requirements + +### Overview & Goals +Make the persistence layer (`me.bobert.netneutrality.db` and subpackages) correct, testable and idiomatic for Hibernate 7.1 / Java 25. Services become pure domain code with no JDA or command types. User-visible bot behavior stays the same except for the bug fixes (writes actually persist, errors surface). + +> Chosen options: **Full redesign**, **injected generic base repository**, **Testcontainers MySQL** tests. + +### Scope +**In scope** +- Fixes for bugs B1–B7 (see Findings). +- Replace the static `JpaUtils` with constructor-injected `SessionProvider` and a generic `AbstractRepository`. +- A single `DatabaseConfig` shared by Hibernate and Flyway. Migrate first, then build the factory with `hbm2ddl.auto=validate`. +- Service cleanup: inject repositories, safe cache swaps, no JDA types in repositories. +- **Decouple services from JDA and `CommandExecutionResult`.** Services take plain IDs and return domain results. The callers (`BlacklistCommand`, `ConfigureCommand`, `MusicCommand`, `MusicManager`, `EventsHandler`, `ActionRoutes`, `BotState`, `TaskManagerImpl`, `LoadPlaylistTask`) map those results to replies. +- **Rework the guild-service cache.** `GuildServiceProvider` keys by guild ID only, evicts on `GuildLeaveEvent`, and exposes read-only snapshots. +- Entity hygiene and consistent logging (Log4j API, matching `App` and `SessionManager`). +- Testcontainers-based integration tests. + +**Out of scope** +- Switching to Spring or JPA `EntityManager`, or changing the Flyway schema (a migration is added only if validation shows an actual mismatch). +- Changing command UX: slash command names and options, and the reply wording (messages stay equivalent). +- Changes to `CommandExecutionResult` itself or to the command framework (`core.command`). + +### Functional Requirements +- Adding or removing a blacklist word persists, and the cache reflects it right away. +- Setting a guild setting to a non-default value inserts or updates it. Setting it to the default or `null` deletes the row. All of this happens in one transaction. +- Persistence failures propagate as exceptions and are rolled back. They are never silently logged. +- When the bot starts: Flyway migrates first, then Hibernate validates the schema. If either fails, startup aborts with a clear error (Sentry is kept for non-dev environments). The password is never logged. +- A missing `OGuild` gets recreated through `GuildService.findOrCreateGuild` instead of the `tryLoad` side channel. +- No class in `db.service` or `db.repository` imports `net.dv8tion.jda.*` or `me.bobert.netneutrality.core.command.*`. +- The blacklist add/remove replies stay equivalent: success, "already exists" and "not found" become distinct outcomes, and unexpected errors are shown as a warning by the command. +- When the bot leaves a guild, its row is deleted and its cached `GuildService` is evicted. + +# Technical Design + +### Key Decisions +1. **Constructor injection instead of a static locator.** Repositories receive the `SessionProvider` (not the `SessionFactory`), because the factory is only built in `start()`. `JpaUtils` is deleted. +2. **Use Hibernate 7's native helpers** `SessionFactory.fromTransaction` / `inTransaction` / `fromSession`. They handle begin, commit, rollback and close correctly, so no hand-written transaction code is needed. +3. **Generic ID type.** `AbstractRepository` takes the entity class and uses `session.find`, `merge` and `remove` on a managed instance. +4. **Queries and writes run in the same session.** This fixes B1 and B2 by construction. +5. **Migrate, then validate.** Flyway runs before the factory is built, and `hibernate.hbm2ddl.auto=validate` catches drift between entities and schema. +6. **Services return domain outcomes; commands turn them into replies.** Services return small enums/records such as `BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND }` and never `CommandExecutionResult`. They take `String` IDs, not `Member`/`Guild`/`Message`. Unexpected persistence errors propagate, and the command layer catches them and shows a warning. +7. **ID-only provider API.** `GuildServiceProvider.get(Guild)` is removed (callers pass `guild.getId()`). The provider adds `evict(String)`, and `getServices()` becomes `snapshot()`, which returns `Map.copyOf`. + +### Data Models / Contracts +```java +public record DatabaseConfig(String url, String user, String password) { + static DatabaseConfig from(SettingsProvider s) { ... } // MYSQL_* keys, same defaults +} + +public abstract class AbstractRepository { + protected final SessionProvider sessions; + private final Class type; + protected AbstractRepository(SessionProvider sessions, Class type) + public List findAll() // fromSession + criteria/HQL + public Optional findById(ID id) // session.find + public T save(T entity) // fromTransaction(s -> s.merge(entity)) + public void deleteById(ID id) // inTransaction: find + remove + protected R read(Function work) // sf.fromSession + protected R write(Function work) // sf.fromTransaction + protected void writeVoid(Consumer work) // sf.inTransaction +} +``` +Repositories and their key queries: +- `GuildRepository extends AbstractRepository`. +- `BlacklistRepository`: + - `findByGuildId` + - `findEnabledWords(guildId)` returns `Set` + - `enableWord(OGuild, creatorId, word)` + - `deleteWord(guildId, word)` +- `GuildSettingRepository`: + - `findByGuild` + - `upsertOrDelete(guildId, name, value, default)`, which queries the setting directly instead of using the lazy collection. +- `TaskRepository`. +- `MessageRepository`: + - `findByMessageId` + - `updateEditedMessage` + - `findByUser(... ORDER BY dateSent DESC)` + - `log(...)` takes plain values (IDs, content, `Instant`) instead of JDA types. + +Service contracts after the redesign: +```java +public enum BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND } + +public class BlacklistService { + BlacklistResult add(String creatorUserId, String word); + BlacklistResult remove(String word); + List list(); // record BlacklistEntry(String word, boolean enabled) + boolean containsBlacklistedWord(String text); +} + +public class GuildSettingService { + void set(GSettings key, @Nullable String value); // null/default => delete + Optional get(GSettings key); + Map snapshot(); // unmodifiable +} + +public interface GuildServiceProvider { + GuildService get(String guildId); + void evict(String guildId); + Map snapshot(); +} +``` + +### Proposed Changes +- **`SessionManager`** + - Hold a `DatabaseConfig`. + - `start()` runs `DatabaseMigration.migrate(config)` and then builds the factory eagerly (`synchronized` / `volatile`). + - `getSessionFactory()` throws if called before `start()`. + - Remove `JpaUtils.bind`. + - Add `hibernate.hbm2ddl.auto=validate` to `hibernate.cfg.xml` and map `OWebUser` there if it has a table. +- **`DatabaseMigration`**: takes `DatabaseConfig`. +- **Services** get their dependencies through constructors: + - `GuildService(String guildId, GuildRepository, BlacklistRepository, GuildSettingRepository)`. + - `updateDebugMode` becomes `oGuild = guildRepository.save(modified)`. + - `BlacklistService.reloadBlacklistCache` uses `findEnabledWords` (no `tryLoad`). + - `GuildSettingService` builds a new map and swaps it into a `volatile` field. `getSettings()` returns an unmodifiable view. + - `TaskService` receives `TaskRepository` and uses the field. It parses the payload once. +- **`GuildServiceProvider` / `GuildServiceProviderImpl`** follow the ID-only API: the provider is constructed with the repositories, uses `ConcurrentHashMap.computeIfAbsent`, and has `evict` and `snapshot`. +- **Callers updated to the new service API:** + - `BlacklistCommand` maps `BlacklistResult` to success/warn replies, passes `context.getGuildUser().getId()`, and lists entries through `list()`. + - `ConfigureCommand` uses `set(...)` / `snapshot()`. + - `MusicCommand`, `MusicManager`, `MusicServiceImpl`, `ActionRoutes`, `BotState`, `LoadPlaylistTask` and `TaskManagerImpl` use `get(String)` and `snapshot()`. + - `EventsHandler`: on `GuildLeaveEvent` it calls `deleteGuild()` and then `evict(id)`. +- **`App.java`**: + - Build the repositories from the registered `SessionManager`. + - Pass them to `GuildServiceProviderImpl` and `TaskService`. + - Remove `JpaUtils.bindGuildServiceLookup`. + - Update the callers of `MessageRepository.log`. +- **Entities**: + - Add ID-based `equals`/`hashCode` (with a null-safe `getClass`/proxy-aware pattern). + - Rename `OLoggings` to `loggings`. +- **Cleanup**: fix the headers, standardize on the Log4j API, and document `GSettings`. + +### File Structure +- **New**: + - `db/DatabaseConfig.java` + - `db/service/BlacklistResult.java`, `db/service/BlacklistEntry.java` + - `db/AbstractRepository.java` (replaces `db/Repository.java`) + - `src/test/java/me/bobert/netneutrality/db/**` tests +- **Modified**: + - `SessionManager`, `SessionProvider` (if needed), `DatabaseMigration` + - all 5 repositories, all services, `GuildServiceProvider` + - the callers: `BlacklistCommand`, `ConfigureCommand`, `MusicCommand`, `MusicManager`, `MusicServiceImpl`, `EventsHandler`, `ActionRoutes`, `BotState`, `TaskManagerImpl`, `LoadPlaylistTask` + - the entities, `hibernate.cfg.xml`, `App.java`, `pom.xml` (test dependencies) +- **Deleted**: `db/JpaUtils.java`, `db/Repository.java`. + +### Architecture Diagram +```mermaid +graph LR +App --> SessionManager +SessionManager -->|migrate first| DatabaseMigration +SessionManager -->|builds| SF[SessionFactory] +App --> Repos[AbstractRepository subclasses] +Repos -->|fromTransaction| SF +App --> GSP[GuildServiceProviderImpl] +GSP --> GuildService +GuildService --> BlacklistService +GuildService --> GuildSettingService +BlacklistService --> Repos +GuildSettingService --> Repos +App --> TaskService +TaskService --> Repos +``` + +### Risks +- **`validate` may fail on existing drift** between the entities and V1/V2. Mitigation: the integration test catches it; fix the mapping or add `V3__*.sql`. +- **Callers relied on swallowed exceptions.** Commands such as `BlacklistCommand` and `ConfigureCommand` catch them and show a warning reply. The listener and `LogHandler` paths are reviewed so errors stay logged and don't crash event handling. +- **Wide caller change set** from the API redesign. Mitigation: do it in its own stage, keep the method semantics identical, and rely on the compiler and `./mvnw test` to catch every call site. +- **Lazy associations accessed outside a session** (for example `OGuild.getBlacklists()` elsewhere). Mitigation: search for the getters and replace them with repository queries. + +# Testing + +### Validation Approach +- Run `./mvnw test` after each stage. Existing tests (for example `ApplicationContextTest`) must keep passing. +- Add the `org.testcontainers:mysql` and `junit-jupiter` test dependencies. +- Add a shared `MySqlTestBase` that: + - starts `mysql:8` + - builds a `DatabaseConfig` / `SettingsProvider` stub + - starts a real `SessionManager` (Flyway plus validate) + - uses `@Testcontainers(disabledWithoutDocker = true)` + +### Key Scenarios +- `SessionManager` starts against the container: the migrations apply and schema validation passes. +- `BlacklistRepository`: adding a word and then deleting it removes the row (regression for B1). Re-adding a disabled word re-enables it. +- `GuildSettingRepository`: setting, updating and resetting to default each persist the expected state (regression for B2). +- `AbstractRepository`: `findById` / `deleteById` work with `Integer` and `String` IDs (B3). A failing write throws and leaves no partial data (B4). +- `GuildService.findOrCreateGuild` creates the guild once, and a second call returns the existing row. +- `GuildSettingService`: readers never observe an empty cache during a reload (unit test with Mockito). +- `BlacklistService` returns `ADDED`, then `ALREADY_EXISTS`, then `REMOVED`, then `NOT_FOUND` for the matching sequence of calls (container test). +- `BlacklistCommand` maps each `BlacklistResult` (and a thrown exception) to the expected reply type (Mockito unit test). +- `GuildServiceProviderImpl.evict` removes the cached service, and the next `get` builds a new one. +- Architecture check: a test scans `db.service` / `db.repository` classes to confirm there are no `net.dv8tion.jda` or `core.command` imports (a simple source-scan test). + +### Edge Cases +- `MessageRepository.findByMessageId` for an unknown ID returns empty (no NPE). +- `SessionManager.getSessionFactory()` before `start()` throws `IllegalStateException`. +- `TaskService.persistTask` with an unregistered type is a no-op. + +# Delivery Steps + +### ✓ Step 1: Rework session bootstrap and add a generic base repository +SessionManager migrates first, then builds a validated SessionFactory, and repositories can extend a type-safe injected base class. + +- Add `db/DatabaseConfig` record built from the `MYSQL_*` settings. Use it in both `SessionManager` and `DatabaseMigration`. +- `SessionManager.start()`: + - runs Flyway first + - then builds the factory eagerly and thread-safely + - keeps the Sentry/dev logic + - does not log the password +- `getSessionFactory()` throws before `start()`. +- Add `hibernate.hbm2ddl.auto=validate` to `hibernate.cfg.xml` and map `OWebUser` if it has a table. +- Add `AbstractRepository`, which takes a `SessionProvider` and implements: + - `findAll` + - `findById` returning `Optional` + - `save` returning the merged entity + - `deleteById` + - `read`/`write` helpers built on `fromSession`/`fromTransaction`/`inTransaction` +- Add the Testcontainers test dependencies and a `MySqlTestBase`, plus a startup and schema validation test. + +### ✓ Step 2: Migrate repositories onto the base class and fix persistence bugs +All five repositories use injected sessions with correct ID types, and the blacklist and setting write bugs are fixed and covered by tests. + +- Convert `GuildRepository`, `BlacklistRepository`, `GuildSettingRepository`, `TaskRepository` and `MessageRepository` to `AbstractRepository` with proper ID types. +- Blacklist: + - `enableWord` / `deleteWord` query and modify inside the same transaction (fixes the detached remove). + - Add `findEnabledWords(guildId)`. +- Guild settings: `upsertOrDelete` runs in a transaction and queries the setting directly. +- Messages: + - JDA-free `log(...)` + - `updateEditedMessage` + - `Optional` lookups + - ordered `findByUser` +- Remove the swallowed-exception patterns and fix the duplicated headers and package lines. +- Add repository integration tests for the blacklist, settings, ID types and rollback scenarios. + +### ✓ Step 3: Refactor services and wiring, and remove JpaUtils +Services receive their repositories through constructors, caches are thread-safe, and the static `JpaUtils` and `Repository` classes are gone. + +- `GuildService`: + - takes its repositories through the constructor + - `updateDebugMode` uses `save` + - `findOrCreateGuild` handles missing guilds, replacing `tryLoad` +- `BlacklistService` reloads its cache through `findEnabledWords`. +- `GuildSettingService` swaps in a new cache map and exposes it unmodifiable. +- `TaskService`: + - uses the injected `TaskRepository` field + - parses the payload once +- `GuildServiceProviderImpl` is constructed with the repositories and uses `computeIfAbsent`. The API changes come in Step 4. +- Update `App.java`: + - build the repositories from `SessionManager` + - remove `JpaUtils.bindGuildServiceLookup` + - update the `MessageRepository.log` callers +- Delete `JpaUtils.java` and `Repository.java`. Replace any lazy-collection access outside a session with repository queries. +- Add service tests: Mockito for the caches, and the container test for `findOrCreateGuild`. + +### ✓ Step 4: Decouple services from JDA and command types, and update callers +Services expose JDA-free, ID-based domain APIs, and every caller maps domain results to replies. + +- Add `BlacklistResult` and `BlacklistEntry`. `BlacklistService.add/remove/list` take plain IDs and return domain results instead of `CommandExecutionResult`. +- `GuildSettingService` exposes `set` / `get` / `snapshot`. +- `GuildServiceProvider` becomes ID-only: remove `get(Guild)`, add `evict`, and replace `getServices` with `snapshot`. +- Update the callers: + - `BlacklistCommand` maps results and exceptions to replies + - `ConfigureCommand` + - `MusicCommand`, `MusicManager`, `MusicServiceImpl` + - `ActionRoutes`, `BotState` + - `TaskManagerImpl`, `LoadPlaylistTask` + - `EventsHandler` evicts on guild leave +- Add tests: the `BlacklistService` result sequence (container), `BlacklistCommand` reply mapping (Mockito), provider eviction, and the no-JDA-imports source scan. + +### ✓ Step 5: Entity and package hygiene +Entities follow JPA best practices and the package uses consistent conventions, with the full test suite green. + +- Add ID-based, proxy-safe `equals`/`hashCode` to the entities. +- Rename `OGuild.OLoggings` to `loggings`. +- Standardize logging on the Log4j API across `db.service`. +- Document the `GSettings` entries by resolving the TODO. +- Re-run schema validation. If entity/schema drift shows up, add a `V3__*.sql` migration or fix the mapping. +- Run `./mvnw test` to confirm the existing and new tests pass. \ No newline at end of file diff --git a/pom.xml b/pom.xml index f7d1422..5406bed 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ 25 25 5.23.0 + 2.0.5 @@ -273,6 +274,18 @@ ${mockito.version} test + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-mysql + ${testcontainers.version} + test + \ No newline at end of file diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java index 01ff453..08a5ac4 100644 --- a/src/main/java/me/bobert/netneutrality/App.java +++ b/src/main/java/me/bobert/netneutrality/App.java @@ -28,7 +28,10 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider; import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.utils.ChannelMessagePurger; -import me.bobert.netneutrality.db.JpaUtils; +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import me.bobert.netneutrality.db.repository.TaskRepository; import me.bobert.netneutrality.utils.Settings; import me.bobert.netneutrality.http.WebModule; import net.dv8tion.jda.api.JDA; @@ -114,9 +117,11 @@ public class App { // Allow ApplicationContext itself to be injected into components that need it. context.register(context); - // Persistence: build the SessionFactory + run migrations; released last on shutdown. - // SessionManager binds itself into JpaUtils for repository access. - context.register(new SessionManager(settings)); + // Persistence: run migrations + build the SessionFactory; released last on shutdown. + SessionManager sessions = context.register(new SessionManager(settings)); + GuildRepository guildRepository = new GuildRepository(sessions); + BlacklistRepository blacklistRepository = new BlacklistRepository(sessions); + GuildSettingRepository guildSettingRepository = new GuildSettingRepository(sessions); // Executors: no explicit start today; shut down after music players are disconnected. ExecutorProvider executors = context.register(new ThreadManager()); @@ -130,12 +135,12 @@ public class App { CommandRegistry commands = context.register(new CommandManager(context)); // Task registry (priority 30). - TaskManager tasks = context.register(new TaskManagerImpl(context)); + TaskManager tasks = context.register(new TaskManagerImpl(context, new TaskRepository(sessions))); // Per-guild service cache (not a Lifecycle component). - GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl()); + GuildServiceProvider guildServices = context.register( + new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository)); LogHandler.bind(guildServices); - JpaUtils.bindGuildServiceLookup(guildServices::get); // Music sources (priority 40): initialise the shared player manager; disconnect on shutdown. music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors)); diff --git a/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java b/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java index 81840ea..11415cc 100644 --- a/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java +++ b/src/main/java/me/bobert/netneutrality/bootstrap/BotState.java @@ -42,7 +42,7 @@ public class BotState { return; } - Map services = guildServices.getServices(); + Map services = guildServices.snapshot(); ObjectNode main = Json.object(); ArrayNode guildsArray = Json.array(); @@ -56,7 +56,7 @@ public class BotState { ObjectNode guild = Json.object(); ObjectNode music = Json.object(); - guild.put("id", service.getEntity().getGuildId()); + guild.put("id", service.getGuildId()); // MessageChannel currentChannel = trackScheduler.getTextChannel(); // if (currentChannel == null) { diff --git a/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java b/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java index af232bb..e136338 100644 --- a/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/admin/BlacklistCommand.java @@ -10,16 +10,20 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.AdminCommand; import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.db.entities.OBlacklist; +import me.bobert.netneutrality.db.service.BlacklistEntry; +import me.bobert.netneutrality.db.service.BlacklistResult; import me.bobert.netneutrality.db.service.BlacklistService; +import me.bobert.netneutrality.db.service.GuildServiceProvider; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.util.List; import java.util.Objects; -import me.bobert.netneutrality.db.service.GuildServiceProvider; public class BlacklistCommand extends AdminCommand { + private static final Logger logger = LogManager.getLogger(BlacklistCommand.class); private final GuildServiceProvider guildServices; @@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand { public CommandExecutionResult execute(CommandContext context) { String sub = context.getSubcommand(); String word = context.getOption("word", "", CommandOption::getAsString); - BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService(); + BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService(); switch (sub) { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { - List blacklists = service.getBlacklistForGuild(); + List blacklists = service.list(); StringBuilder sb = new StringBuilder(); sb.append("List of blacklisted words for this server: \n"); - for (OBlacklist bl : blacklists) { - sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n"); + for (BlacklistEntry bl : blacklists) { + sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n"); } if (blacklists.isEmpty()) { @@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand { asyncMessage.edit(sb.toString()); }); case "add" -> { - return service.add(context.getGuildUser(), word); + try { + return toReply(service.add(context.getGuildUser().getId(), word), word); + } catch (RuntimeException e) { + logger.error("Failed to add word to blacklist", e); + return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage()); + } } case "remove" -> { - return service.delete(word); + try { + return toReply(service.remove(word), word); + } catch (RuntimeException e) { + logger.error("Failed to remove word from blacklist", e); + return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage()); + } } } return CommandExecutionResult.none(); } + + static CommandExecutionResult toReply(BlacklistResult result, String word) { + return switch (result) { + case ADDED, REENABLED -> CommandExecutionResult.success("Added the word [%s] to the blacklist.", word); + case ALREADY_EXISTS -> CommandExecutionResult.warn(String.format("The word [%s] is already in the blacklist.", word)); + case REMOVED -> CommandExecutionResult.success("Removed the word [%s] from the blacklist.", word); + case NOT_FOUND -> CommandExecutionResult.warn(String.format("The word [%s] is not in the blacklist.", word)); + }; + } } diff --git a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java index 3a5b674..c31cecd 100644 --- a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java @@ -12,19 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.db.service.GuildSettingService; import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; - -import java.util.Map; import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.music.MusicService; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Map; public class ConfigureCommand extends AdminCommand { + private static final Logger logger = LogManager.getLogger(ConfigureCommand.class); private final GuildServiceProvider guildServices; private final MusicService musicService; @@ -76,8 +80,18 @@ public class ConfigureCommand extends AdminCommand { @Override public CommandExecutionResult execute(CommandContext context) { + try { + return configure(context); + } catch (RuntimeException e) { + logger.error("Failed to update configuration", e); + return CommandExecutionResult.warn("Failed to update configuration: " + e.getMessage()); + } + } + + private CommandExecutionResult configure(CommandContext context) { String sub = context.getSubcommand(); GuildService guildService = guildServices.get(context.getGuild().getId()); + GuildSettingService settingService = guildService.getGuildSettingService(); context.ephemeral(); if ("music-role".equals(sub)) { @@ -85,13 +99,13 @@ public class ConfigureCommand extends AdminCommand { assert role != null; if (role.getPosition() == -1) { - guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); + settingService.set(GSettings.MUSIC_ROLE, null); return CommandExecutionResult.success("Everyone can now use music commands"); } if (role.getGuild().getId().equals(context.getGuild().getId())) { - guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, role.getId()); + settingService.set(GSettings.MUSIC_ROLE, role.getId()); return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention())); } @@ -101,14 +115,14 @@ public class ConfigureCommand extends AdminCommand { GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel); if (channel == null) { - guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, null); + settingService.set(GSettings.MUSIC_CHANNEL, null); return CommandExecutionResult.success("No channel restrictions for music commands"); } if (channel instanceof TextChannel) { if (channel.getGuild().getId().equals(context.getGuild().getId())) { - guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, channel.getId()); + settingService.set(GSettings.MUSIC_CHANNEL, channel.getId()); return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention())); } @@ -118,7 +132,7 @@ public class ConfigureCommand extends AdminCommand { if ("music-volume".equals(sub)) { int volume = context.getOption("volume", 30, CommandOption::getAsInt); - guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume)); + settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume)); musicService.get(context.getGuild()).setVolume(volume); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); @@ -132,7 +146,7 @@ public class ConfigureCommand extends AdminCommand { } if ("show-all".equals(sub)) { - Map settings = guildService.getGuildSettingService().getSettings(); + Map settings = settingService.snapshot(); MessageCreateBuilder builder = new MessageCreateBuilder(); for (GSettings o : GSettings.values()) { diff --git a/src/main/java/me/bobert/netneutrality/core/LogHandler.java b/src/main/java/me/bobert/netneutrality/core/LogHandler.java index 12861a3..fe40856 100644 --- a/src/main/java/me/bobert/netneutrality/core/LogHandler.java +++ b/src/main/java/me/bobert/netneutrality/core/LogHandler.java @@ -92,7 +92,7 @@ public class LogHandler { if (context.getGuild() != null) { GuildServiceProvider provider = guildServices; - if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) { + if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) { context.getGuild() .getTextChannels() .stream() diff --git a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java index c90ae07..4f99f65 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java +++ b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java @@ -43,7 +43,7 @@ public abstract class MusicCommand extends AbstractCommand { * @return the guild service */ protected final GuildService guildService(Guild guild) { - return guildServices.get(guild); + return guildServices.get(guild.getId()); } /** @@ -79,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand { Guild guild = member.getGuild(); - String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE); + String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE); if (roleDb != null && !roleDb.isEmpty()) { if (UserFunction.isAdmin(member)) { @@ -89,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand { Role neededRole = guild.getRoleById(roleDb); if (neededRole == null) { // Role doesn't exist on guild anymore, remove from db - guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); + guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null); return true; } @@ -106,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand { return false; } - String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); + String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getOrDefault(GSettings.MUSIC_CHANNEL); if (channelDb != null && !channelDb.isEmpty()) { return channelDb.equals(channel.getId()); } diff --git a/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java index a518ab0..679fde4 100644 --- a/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java +++ b/src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java @@ -16,6 +16,7 @@ import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithParent; import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry; +import me.bobert.netneutrality.db.repository.TaskRepository; import me.bobert.netneutrality.db.service.TaskService; import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.TaskExecutionException; @@ -52,9 +53,10 @@ public class TaskManagerImpl implements TaskManager { * Creates a task manager that can inject context-registered dependencies into reflectively * loaded tasks. * - * @param context the application context; must not be {@code null} + * @param context the application context; must not be {@code null} + * @param taskRepository the repository persisting {@link PersistableTask}s */ - public TaskManagerImpl(ApplicationContext context) { + public TaskManagerImpl(ApplicationContext context, TaskRepository taskRepository) { Objects.requireNonNull(context, "ApplicationContext must not be null"); this.tasks = new ConcurrentHashMap<>(); this.pendingTasks = new ConcurrentHashMap<>(); @@ -62,7 +64,7 @@ public class TaskManagerImpl implements TaskManager { this.tasksLock = new ConcurrentHashMap<>(); this.context = context; this.persistableTaskRegistry = new PersistableTaskRegistry(context); - this.taskService = new TaskService(this, persistableTaskRegistry); + this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository); } @Override diff --git a/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java b/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java new file mode 100644 index 0000000..5feb0b4 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java @@ -0,0 +1,95 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import jakarta.persistence.criteria.CriteriaQuery; +import org.hibernate.Session; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Generic, constructor-injected Hibernate repository. + * + *

All work runs through Hibernate's native {@code fromSession}/{@code fromTransaction}/ + * {@code inTransaction} helpers, which open and close the session and commit or roll back the + * transaction. Exceptions are never swallowed: a failed write is rolled back and rethrown. + * + * @param the entity type + * @param the entity identifier type + */ +public abstract class AbstractRepository { + + protected final SessionProvider sessions; + private final Class type; + + protected AbstractRepository(SessionProvider sessions, Class type) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.type = Objects.requireNonNull(type, "type"); + } + + protected Class type() { + return type; + } + + public List findAll() { + return read(session -> { + CriteriaQuery query = session.getCriteriaBuilder().createQuery(type); + query.from(type); + return session.createSelectionQuery(query).getResultList(); + }); + } + + public Optional findById(ID id) { + Objects.requireNonNull(id, "id"); + return read(session -> Optional.ofNullable(session.find(type, id))); + } + + /** + * Inserts or updates the entity. + * + * @param entity the entity to save + * @return the persistent (merged) state, which callers should use from then on + */ + public T save(T entity) { + Objects.requireNonNull(entity, "entity"); + return write(session -> session.merge(entity)); + } + + /** + * Deletes the entity with the given id, if it exists. + * + * @param id the identifier + * @return {@code true} if a row was deleted + */ + public boolean deleteById(ID id) { + Objects.requireNonNull(id, "id"); + return write(session -> { + T entity = session.find(type, id); + if (entity == null) { + return false; + } + session.remove(entity); + return true; + }); + } + + protected R read(Function work) { + return sessions.getSessionFactory().fromSession(work); + } + + protected R write(Function work) { + return sessions.getSessionFactory().fromTransaction(work); + } + + protected void writeVoid(Consumer work) { + sessions.getSessionFactory().inTransaction(work); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/DatabaseConfig.java b/src/main/java/me/bobert/netneutrality/db/DatabaseConfig.java new file mode 100644 index 0000000..0e563f5 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/DatabaseConfig.java @@ -0,0 +1,48 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.utils.SettingsProvider; + +import java.util.Objects; + +/** + * JDBC connection settings shared by Flyway ({@link DatabaseMigration}) and Hibernate + * ({@link SessionManager}), so both always connect to the same database. + * + * @param url the JDBC url + * @param user the database user + * @param password the database password (never logged, see {@link #toString()}) + */ +public record DatabaseConfig(String url, String user, String password) { + + public DatabaseConfig { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(user, "user"); + password = password == null ? "" : password; + } + + /** + * Builds the config from the {@code MYSQL_*} settings. + * + * @param settings the bot settings + * @return the database config + */ + public static DatabaseConfig from(SettingsProvider settings) { + String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); + String dbUser = settings.get("MYSQL_USER", "root"); + String dbPass = settings.get("MYSQL_PASSWORD", ""); + String dbHost = settings.get("MYSQL_HOST", "localhost"); + String dbPort = settings.get("MYSQL_PORT", "3306"); + return new DatabaseConfig(String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName), dbUser, dbPass); + } + + @Override + public String toString() { + return "DatabaseConfig[url=" + url + ", user=" + user + ", password=***]"; + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java b/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java index 90421b7..17e2462 100644 --- a/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java +++ b/src/main/java/me/bobert/netneutrality/db/DatabaseMigration.java @@ -1,37 +1,38 @@ package me.bobert.netneutrality.db; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.utils.SettingsProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.flywaydb.core.Flyway; import org.flywaydb.core.api.output.MigrateResult; -public class DatabaseMigration { - private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); +public final class DatabaseMigration { + private static final Logger logger = LogManager.getLogger(DatabaseMigration.class); - public static void migrate(SettingsProvider settings) { - String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); - String dbUser = settings.get("MYSQL_USER", "root"); - String dbPass = settings.get("MYSQL_PASSWORD", ""); - String dbHost = settings.get("MYSQL_HOST", "localhost"); - String dbPort = settings.get("MYSQL_PORT", "3306"); - String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName); + private DatabaseMigration() { + } + /** + * Applies all pending Flyway migrations to the configured database. + * + * @param config the database connection settings + * @throws IllegalStateException if the migration fails + */ + public static void migrate(DatabaseConfig config) { try { logger.info("Running database migrations.."); Flyway flyway = Flyway.configure() - .dataSource(dbUrl, dbUser, dbPass) + .dataSource(config.url(), config.user(), config.password()) .load(); MigrateResult result = flyway.migrate(); logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted); } catch (Exception e) { - logger.error(e); throw new IllegalStateException("Database migration failed", e); } } diff --git a/src/main/java/me/bobert/netneutrality/db/GSettings.java b/src/main/java/me/bobert/netneutrality/db/GSettings.java index 64f094a..fead52b 100644 --- a/src/main/java/me/bobert/netneutrality/db/GSettings.java +++ b/src/main/java/me/bobert/netneutrality/db/GSettings.java @@ -1,15 +1,26 @@ package me.bobert.netneutrality.db; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ +/** + * Per-guild settings stored in the {@code guild_settings} table. + * + *

Only values that differ from the default are stored; setting a value back to its default (or + * {@code null}) deletes the row. {@link #getName()} is the key persisted in the {@code name} + * column and must never change for an existing setting. + */ public enum GSettings { - //TODO: description + /** Id of the role required to use music commands; empty means everyone may use them. */ MUSIC_ROLE("MUSIC_ROLE", ""), + /** Id of the only text channel music commands may be used in; empty means any channel. */ MUSIC_CHANNEL("MUSIC_CHANNEL", ""), + /** Default playback volume (0-100) applied when a music player is created. */ MUSIC_VOLUME("MUSIC_VOLUME", "30"), + /** Playlist size above which the bot asks for confirmation before queueing every track. */ MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20"); private final String name; diff --git a/src/main/java/me/bobert/netneutrality/db/JpaUtils.java b/src/main/java/me/bobert/netneutrality/db/JpaUtils.java deleted file mode 100644 index baf02af..0000000 --- a/src/main/java/me/bobert/netneutrality/db/JpaUtils.java +++ /dev/null @@ -1,158 +0,0 @@ -package me.bobert.netneutrality.db; - -/* - * Copyright (c) anthorob - * - * https://discord.gg/qUa6zNFZsd - */ - -import me.bobert.netneutrality.db.entities.OGuild; -import me.bobert.netneutrality.db.service.GuildService; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.hibernate.HibernateException; -import org.hibernate.ObjectNotFoundException; -import org.hibernate.Session; -import org.hibernate.SessionFactory; -import org.hibernate.Transaction; -import org.hibernate.exception.ConstraintViolationException; - -import java.io.Serializable; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * Static Hibernate helpers used by repositories and domain services. - * - *

Because repositories are created deep in the object graph without constructor injection, - * this class holds a single {@link SessionProvider} bound once during bootstrap via - * {@link #bind(SessionProvider)}. That is the only global access point for sessions — there is no - * {@code SessionManager.instance()} shim. - */ -public class JpaUtils { - - private static final Logger logger = LogManager.getLogger(JpaUtils.class); - private static volatile SessionProvider sessions; - private static volatile Function guildServiceLookup; - - private JpaUtils() { - } - - /** - * Binds the application {@link SessionProvider}. Called once from {@code SessionManager}'s - * constructor during context registration. - * - * @param provider the live session provider - */ - public static void bind(SessionProvider provider) { - sessions = Objects.requireNonNull(provider, "SessionProvider"); - } - - /** - * Optional guild-service lookup used when recovering a missing {@link OGuild} entity. Bound from - * bootstrap once the {@code GuildServiceProvider} is available. - * - * @param lookup guild-id → service resolver - */ - public static void bindGuildServiceLookup(Function lookup) { - guildServiceLookup = Objects.requireNonNull(lookup, "guildServiceLookup"); - } - - private static SessionFactory factory() { - SessionProvider provider = sessions; - if (provider == null) { - throw new IllegalStateException("JpaUtils has not been bound to a SessionProvider"); - } - return provider.getSessionFactory(); - } - - /** - * Tries to read the persistent state into the object. - *

- * If the object is not found in the database and is of type {@link OGuild}, tries to recreate entity and throws - * an exception to block execution. - *

- * - * @param session The session in which to load the persistent object - * @param object The persistent object - * @param id Identifier - */ - public static void tryLoad(Session session, Object object, Serializable id) { - try { - session.load(object, id); - } catch (HibernateException exception) { - if (exception instanceof ObjectNotFoundException) { - if (object instanceof OGuild oGuild) { - String cachedId = oGuild.getGuildId(); - logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId); - - Function lookup = guildServiceLookup; - if (lookup == null) { - session.close(); - throw exception; - } - GuildService service = lookup.apply(cachedId); - service.reloadEntity(); - service.getBlacklistService().removeGuildBlacklist(); - logger.info("Guild[{}] reloaded", cachedId); - } - } - - session.close(); - throw exception; - } - } - - public static Session openSession() { - return factory().openSession(); - } - - public static void runWithSession(Consumer consumer) { - try (Session session = factory().openSession()) { - consumer.accept(session); - } - } - - public static void runWithTransaction(Consumer consumer) { - try (Session session = factory().openSession()) { - session.beginTransaction(); - consumer.accept(session); - session.getTransaction().commit(); - } - } - - public static void updateWithTransaction(Object persistantEntity, Runnable runnable) { - try (Session session = factory().openSession()) { - Transaction transaction = session.beginTransaction(); - - try { - runnable.run(); - - session.merge(persistantEntity); - transaction.commit(); - } catch (ConstraintViolationException e) { - logger.error(e); - throw e; - } catch (Exception e) { - logger.error(e); - transaction.rollback(); - } - } - } - - /** - * Loads the entity and relations from the database and executes the given consumer. - * This will load all Lazy relationships defined in the entity. - * - * @param persistantEntity The entity to load - * @param id The identifier for the entity - * @param consumer The comsumer, which exposes the session - */ - public static void getWithSession(Object persistantEntity, Serializable id, Consumer consumer) { - try (Session session = factory().openSession()) { - tryLoad(session, persistantEntity, id); - consumer.accept(session); - } - } -} diff --git a/src/main/java/me/bobert/netneutrality/db/Repository.java b/src/main/java/me/bobert/netneutrality/db/Repository.java deleted file mode 100644 index e3b1184..0000000 --- a/src/main/java/me/bobert/netneutrality/db/Repository.java +++ /dev/null @@ -1,43 +0,0 @@ -package me.bobert.netneutrality.db; -/* - * Copyright (c) anthorob - * - * https://discord.gg/qUa6zNFZsd - */ - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.hibernate.Session; -import org.hibernate.Transaction; -import org.hibernate.exception.ConstraintViolationException; - -import java.util.List; - -public interface Repository { - Logger logger = LogManager.getLogger(Repository.class); - - List findAll(); - - O findById(String id); - - default void save(O entity) { - Transaction transaction = null; - - try (Session session = JpaUtils.openSession()) { - transaction = session.beginTransaction(); - session.merge(entity); - transaction.commit(); - } catch (ConstraintViolationException e) { - logger.error(e); - throw e; - } catch (Exception e) { - logger.error(e); - - if (transaction != null) { - transaction.rollback(); - } - } - } - - void deleteBy(String id); -} diff --git a/src/main/java/me/bobert/netneutrality/db/SessionManager.java b/src/main/java/me/bobert/netneutrality/db/SessionManager.java index 7e15023..b78f080 100644 --- a/src/main/java/me/bobert/netneutrality/db/SessionManager.java +++ b/src/main/java/me/bobert/netneutrality/db/SessionManager.java @@ -1,4 +1,5 @@ package me.bobert.netneutrality.db; + /* * Copyright (c) anthorob * @@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.hibernate.SessionFactory; -import org.hibernate.boot.Metadata; import org.hibernate.boot.MetadataSources; import org.hibernate.boot.registry.StandardServiceRegistry; import org.hibernate.boot.registry.StandardServiceRegistryBuilder; @@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment; import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** * Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its * service registry. * - *

On construction the instance is bound into {@link JpaUtils} so repositories and other static - * persistence helpers can open sessions without a global {@code SessionManager} singleton. + *

{@link #start()} first applies the Flyway migrations and only then builds the session factory, + * which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}). */ public class SessionManager implements SessionProvider { private static final Logger logger = LogManager.getLogger(SessionManager.class); - private final SettingsProvider settings; + private final DatabaseConfig config; + private final boolean reportErrors; private StandardServiceRegistry registry; - private SessionFactory sessionFactory; + private volatile SessionFactory sessionFactory; public SessionManager(SettingsProvider settings) { - this.settings = settings; - JpaUtils.bind(this); + this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev"))); + } + + /** + * @param config the database connection settings + * @param reportErrors whether startup failures are reported to Sentry + */ + public SessionManager(DatabaseConfig config, boolean reportErrors) { + this.config = Objects.requireNonNull(config, "config"); + this.reportErrors = reportErrors; } @Override @@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider { } @Override - public void start() { - getSessionFactory(); - DatabaseMigration.migrate(settings); + public synchronized void start() { + if (sessionFactory != null) { + return; + } + + try { + logger.info("Connecting to database '{}' with user '{}'", config.url(), config.user()); + DatabaseMigration.migrate(config); + sessionFactory = buildSessionFactory(); + } catch (RuntimeException e) { + if (reportErrors) { + Sentry.captureException(e); + } + + stop(); + logger.error("Error connecting to database. Shutting down..", e); + throw new IllegalStateException("Unable to connect to the database", e); + } + } + + private SessionFactory buildSessionFactory() { + Map settings = new HashMap<>(); + settings.put(Environment.JAKARTA_JDBC_URL, config.url()); + settings.put(Environment.JAKARTA_JDBC_USER, config.user()); + settings.put(Environment.JAKARTA_JDBC_PASSWORD, config.password()); + + registry = new StandardServiceRegistryBuilder() + .configure() + .applySettings(settings) + .build(); + return new MetadataSources(registry).buildMetadata().buildSessionFactory(); } @Override - public void stop() { + public synchronized void stop() { + SessionFactory factory = sessionFactory; + sessionFactory = null; + if (factory != null) { + factory.close(); + } if (registry != null) { StandardServiceRegistryBuilder.destroy(registry); registry = null; - sessionFactory = null; } } @Override public SessionFactory getSessionFactory() { - if (sessionFactory == null) { - try { - StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure(); - Map config = new HashMap<>(); - String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); - String dbUser = settings.get("MYSQL_USER", "root"); - String dbPass = settings.get("MYSQL_PASSWORD", ""); - String dbHost = settings.get("MYSQL_HOST", "localhost"); - String dbPort = settings.get("MYSQL_PORT", "3306"); - logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser); - - config.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName)); - config.put(Environment.JAKARTA_JDBC_USER, dbUser); - config.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass); - registryBuilder.applySettings(config); - - registry = registryBuilder.build(); - MetadataSources sources = new MetadataSources(registry); - Metadata metadata = sources.getMetadataBuilder().build(); - sessionFactory = metadata.getSessionFactoryBuilder().build(); - - } catch (Exception e) { - String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev"); - if (!"dev".equals(env)) { - Sentry.captureException(e); - } - - stop(); - logger.error("Error connecting to database. Shutting down.."); - throw new IllegalStateException("Unable to connect to the database", e); - } + SessionFactory factory = sessionFactory; + if (factory == null) { + throw new IllegalStateException("SessionManager has not been started"); } - - return sessionFactory; + return factory; } } diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OBlacklist.java b/src/main/java/me/bobert/netneutrality/db/entities/OBlacklist.java index 3c92d90..9b3dac0 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OBlacklist.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OBlacklist.java @@ -15,6 +15,9 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import org.hibernate.Hibernate; + +import java.util.Objects; @Entity @Table(name = "blacklist") @@ -76,4 +79,26 @@ public class OBlacklist { public void setId(Integer id) { this.id = id; } -} \ No newline at end of file + + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OBlacklist other = (OBlacklist) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java b/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java index 0690ee2..de7b23c 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java @@ -11,9 +11,10 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import org.hibernate.Hibernate; import java.util.LinkedHashSet; -import java.util.Optional; +import java.util.Objects; import java.util.Set; @Entity @@ -43,14 +44,14 @@ public class OGuild { private Set logMessages = new LinkedHashSet<>(); @OneToMany(mappedBy = "guild") - private Set OLoggings = new LinkedHashSet<>(); + private Set loggings = new LinkedHashSet<>(); public Set getLoggings() { - return OLoggings; + return loggings; } - public void setLoggings(Set OLoggings) { - this.OLoggings = OLoggings; + public void setLoggings(Set loggings) { + this.loggings = loggings; } public Set getLogMessages() { @@ -110,13 +111,25 @@ public class OGuild { this.guildId = guildId; } - public OGuildSetting getSettingByName(String name) { - Optional setting = getGuildSettings() - .stream() - .filter(setting1 -> setting1.getName().equalsIgnoreCase(name)) - .findFirst(); - - return setting.orElse(null); + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OGuild other = (OGuild) o; + return getGuildId() != null && Objects.equals(getGuildId(), other.getGuildId()); } -} \ No newline at end of file + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java b/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java index fe6ad73..6ad83b3 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java @@ -13,11 +13,13 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; +import org.hibernate.Hibernate; + +import java.util.Objects; @Entity @Table(name = "guild_settings") @@ -33,8 +35,7 @@ public class OGuildSetting { @Column(name = "name", nullable = false) private String name; - @Lob - @Column(name = "value", nullable = false) + @Column(name = "value", nullable = false, columnDefinition = "text") private String value; @ManyToOne(fetch = FetchType.LAZY, optional = false) @@ -72,4 +73,26 @@ public class OGuildSetting { public void setId(Integer id) { this.id = id; } -} \ No newline at end of file + + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OGuildSetting other = (OGuildSetting) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OLogMessage.java b/src/main/java/me/bobert/netneutrality/db/entities/OLogMessage.java index 1814850..ff503ed 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OLogMessage.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OLogMessage.java @@ -16,8 +16,10 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import org.hibernate.Hibernate; import java.time.Instant; +import java.util.Objects; @Entity @Table(name = "log_message") @@ -113,4 +115,26 @@ public class OLogMessage { public void setId(Integer id) { this.id = id; } -} \ No newline at end of file + + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OLogMessage other = (OLogMessage) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OLogging.java b/src/main/java/me/bobert/netneutrality/db/entities/OLogging.java index 09808c6..45ae987 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OLogging.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OLogging.java @@ -14,6 +14,9 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; import jakarta.persistence.Table; +import org.hibernate.Hibernate; + +import java.util.Objects; @Entity @Table(name = "logging") @@ -52,4 +55,26 @@ public class OLogging { public void setId(OLoggingId id) { this.id = id; } -} \ No newline at end of file + + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OLogging other = (OLogging) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OTask.java b/src/main/java/me/bobert/netneutrality/db/entities/OTask.java index 847271c..3022e2c 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OTask.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OTask.java @@ -10,9 +10,11 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import org.hibernate.Hibernate; + +import java.util.Objects; @Entity @Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})}) @@ -31,8 +33,7 @@ public class OTask { @Column(name = "task_class", nullable = false) private String taskClass; - @Lob - @Column(name = "payload") + @Column(name = "payload", columnDefinition = "text") private String payload; public Integer getId() { @@ -67,4 +68,25 @@ public class OTask { this.payload = payload; } -} \ No newline at end of file + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OTask other = (OTask) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OWebUser.java b/src/main/java/me/bobert/netneutrality/db/entities/OWebUser.java index 87e983e..14a3672 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OWebUser.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OWebUser.java @@ -9,11 +9,12 @@ package me.bobert.netneutrality.db.entities; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import org.hibernate.annotations.ColumnDefault; +import org.hibernate.Hibernate; import java.time.Instant; +import java.util.Objects; @Entity @Table(name = "web_users") @@ -22,8 +23,7 @@ public class OWebUser { @Column(name = "id", nullable = false) private String id; - @Lob - @Column(name = "email", nullable = false) + @Column(name = "email", nullable = false, columnDefinition = "mediumtext") private String email; @Column(name = "token", nullable = false) @@ -87,4 +87,25 @@ public class OWebUser { this.rememberToken = rememberToken; } -} \ No newline at end of file + /** + * Identity is the database id: two instances are equal when they have the same non-null id, + * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + OWebUser other = (OWebUser) o; + return getId() != null && Objects.equals(getId(), other.getId()); + } + + @Override + public int hashCode() { + // Constant per class so the hash never changes when the id is assigned on persist. + return Hibernate.getClass(this).hashCode(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java index 6e92f41..c3f0564 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java @@ -1,104 +1,107 @@ package me.bobert.netneutrality.db.repository; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.db.Repository; +import me.bobert.netneutrality.db.AbstractRepository; +import me.bobert.netneutrality.db.SessionProvider; import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.entities.OGuild; -import me.bobert.netneutrality.db.JpaUtils; -import net.dv8tion.jda.api.entities.Member; import org.hibernate.Session; -import org.hibernate.query.Query; -import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; -public class BlacklistRepository implements Repository { +public class BlacklistRepository extends AbstractRepository { - @Override - public List findAll() { - List blacklists = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("FROM OBlacklist", OBlacklist.class); - - blacklists.addAll(query.list()); - }); - - return blacklists; + /** + * Outcome of {@link #enableWord(String, String, String)}. + */ + public enum EnableOutcome { + /** A new row was inserted. */ + CREATED, + /** An existing, disabled row was re-enabled. */ + ENABLED, + /** The word was already enabled. */ + UNCHANGED } - @Override - public OBlacklist findById(String id) { - return null; - } - - @Override - public void deleteBy(String id) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.id = :blacklistId", OBlacklist.class); - query.setParameter("blacklistId", id); - - OBlacklist oTask = query.uniqueResult(); - if (oTask != null) { - session.remove(oTask); - } - }); + public BlacklistRepository(SessionProvider sessions) { + super(sessions, OBlacklist.class); } public List findByGuildId(String guildId) { - List blacklists = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.guild.guildId = :guildId", OBlacklist.class); - query.setParameter("guildId", guildId); - - blacklists.addAll(query.list()); - }); - - return blacklists; + return read(session -> session + .createSelectionQuery("FROM OBlacklist o WHERE o.guild.guildId = :guildId ORDER BY o.id", OBlacklist.class) + .setParameter("guildId", guildId) + .getResultList()); } - public void add(OGuild oGuild, Member member, String word) { - JpaUtils.runWithTransaction(session -> { - OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word); + public Set findEnabledWords(String guildId) { + return read(session -> session + .createSelectionQuery("SELECT o.word FROM OBlacklist o WHERE o.guild.guildId = :guildId AND o.enabled = true", String.class) + .setParameter("guildId", guildId) + .getResultStream() + .collect(Collectors.toUnmodifiableSet())); + } - if (bl == null) { - bl = new OBlacklist(); - bl.setGuild(oGuild); - bl.setCreatorId(member.getId()); - bl.setWord(word); - bl.setEnabled(true); - - session.persist(bl); - } else { - bl.setEnabled(true); - session.merge(bl); + /** + * Adds the word to the guild's blacklist, or re-enables it if it already exists. Lookup and + * write happen in the same transaction. + * + * @param guildId the guild id (the guild row must exist) + * @param creatorId the user adding the word + * @param word the word + * @return what changed + */ + public EnableOutcome enableWord(String guildId, String creatorId, String word) { + return write(session -> { + List existing = findWord(session, guildId, word); + if (existing.isEmpty()) { + OBlacklist blacklist = new OBlacklist(); + blacklist.setGuild(session.getReference(OGuild.class, guildId)); + blacklist.setCreatorId(creatorId); + blacklist.setWord(word); + blacklist.setEnabled(true); + session.persist(blacklist); + return EnableOutcome.CREATED; } - }); - } - public void delete(OGuild oGuild, String word) { - JpaUtils.runWithTransaction(session -> { - OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word); - if (bl != null) { - session.remove(bl); + boolean changed = false; + for (OBlacklist blacklist : existing) { + if (!Boolean.TRUE.equals(blacklist.getEnabled())) { + blacklist.setEnabled(true); + changed = true; + } } + return changed ? EnableOutcome.ENABLED : EnableOutcome.UNCHANGED; }); } - private OBlacklist getWordFromDb(String guildId, String word) { - try (Session session = JpaUtils.openSession()) { - Query query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class); - query.setParameter("guildId", guildId); - query.setParameter("word", word); - OBlacklist blacklist = query.uniqueResult(); + /** + * Deletes the word (case-insensitive) from the guild's blacklist. + * + * @param guildId the guild id + * @param word the word + * @return {@code true} if at least one row was deleted + */ + public boolean deleteWord(String guildId, String word) { + return write(session -> { + List existing = findWord(session, guildId, word); + existing.forEach(session::remove); + return !existing.isEmpty(); + }); + } - session.close(); - return blacklist; - } + private static List findWord(Session session, String guildId, String word) { + return session + .createSelectionQuery("FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class) + .setParameter("guildId", guildId) + .setParameter("word", word) + .getResultList(); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java index 3cbbddc..3722b3a 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java @@ -1,8 +1,4 @@ -package me.bobert.netneutrality.db.repository;/* - * Copyright (c) anthorob - * - * https://discord.gg/qUa6zNFZsd - */ +package me.bobert.netneutrality.db.repository; /* * Copyright (c) anthorob @@ -10,49 +6,31 @@ package me.bobert.netneutrality.db.repository;/* * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.db.Repository; +import me.bobert.netneutrality.db.AbstractRepository; +import me.bobert.netneutrality.db.SessionProvider; import me.bobert.netneutrality.db.entities.OGuild; -import me.bobert.netneutrality.db.JpaUtils; -import org.hibernate.Session; -import org.hibernate.query.Query; -import java.util.ArrayList; -import java.util.List; +public class GuildRepository extends AbstractRepository { -public class GuildRepository implements Repository { - @Override - public List findAll() { - List guilds = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("FROM OGuild", OGuild.class); - - guilds.addAll(query.list()); - }); - - return guilds; + public GuildRepository(SessionProvider sessions) { + super(sessions, OGuild.class); } - @Override - public OGuild findById(String id) { - try (Session session = JpaUtils.openSession()) { - Query query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class); - query.setParameter("guildId", id); - - return query.uniqueResult(); - } - } - - @Override - public void deleteBy(String id) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class); - query.setParameter("guildId", id); - - OGuild oGuild = query.uniqueResult(); - if (oGuild != null) { - session.remove(oGuild); + /** + * Returns the guild row, inserting it first if it does not exist yet. + * + * @param guildId the Discord guild id + * @return the persistent guild + */ + public OGuild findOrCreate(String guildId) { + return write(session -> { + OGuild guild = session.find(OGuild.class, guildId); + if (guild == null) { + guild = new OGuild(); + guild.setGuildId(guildId); + session.persist(guild); } + return guild; }); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java index c53a05a..a783ac4 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java @@ -1,8 +1,4 @@ -package me.bobert.netneutrality.db.repository;/* - * Copyright (c) anthorob - * - * https://discord.gg/qUa6zNFZsd - */ +package me.bobert.netneutrality.db.repository; /* * Copyright (c) anthorob @@ -10,88 +6,56 @@ package me.bobert.netneutrality.db.repository;/* * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.db.AbstractRepository; import me.bobert.netneutrality.db.GSettings; -import me.bobert.netneutrality.db.Repository; +import me.bobert.netneutrality.db.SessionProvider; import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OGuildSetting; -import me.bobert.netneutrality.db.JpaUtils; -import org.hibernate.Session; -import org.hibernate.query.Query; -import java.util.ArrayList; import java.util.List; -public class GuildSettingRepository implements Repository { - @Override - public List findAll() { - List settings = new ArrayList<>(); +public class GuildSettingRepository extends AbstractRepository { - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("FROM OGuildSetting", OGuildSetting.class); - - settings.addAll(query.list()); - }); - - return settings; - } - - @Override - public OGuildSetting findById(String id) { - try (Session session = JpaUtils.openSession()) { - Query query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class); - query.setParameter("id", id); - - return query.uniqueResult(); - } - } - - @Override - public void deleteBy(String id) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class); - query.setParameter("id", id); - - OGuildSetting setting = query.uniqueResult(); - if (setting != null) { - session.remove(setting); - } - }); + public GuildSettingRepository(SessionProvider sessions) { + super(sessions, OGuildSetting.class); } public List findByGuild(String guildId) { - List settings = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class); - query.setParameter("guildId", guildId); - - settings.addAll(query.list()); - }); - - return settings; + return read(session -> session + .createSelectionQuery("FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class) + .setParameter("guildId", guildId) + .getResultList()); } - public void setSettingValue(OGuild oGuild, GSettings setting, String value) { - JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> { - OGuildSetting oGuildSetting = oGuild.getSettingByName(setting.getName()); + /** + * Stores a non-default value, or deletes the row when the value is {@code null} or equal to the + * setting's default. Runs in a single transaction. + * + * @param guildId the guild id (the guild row must exist) + * @param setting the setting + * @param value the new value, or {@code null} to reset + */ + public void upsertOrDelete(String guildId, GSettings setting, String value) { + writeVoid(session -> { + List existing = session + .createNamedSelectionQuery("settings_findByName", OGuildSetting.class) + .setParameter("id", guildId) + .setParameter("name", setting.getName()) + .getResultList(); if (value == null || setting.getDefaultValue().equals(value)) { - if (oGuildSetting != null) { - session.remove(oGuildSetting); - } + existing.forEach(session::remove); + return; + } + + if (existing.isEmpty()) { + OGuildSetting guildSetting = new OGuildSetting(); + guildSetting.setGuild(session.getReference(OGuild.class, guildId)); + guildSetting.setName(setting.getName()); + guildSetting.setValue(value); + session.persist(guildSetting); } else { - if (oGuildSetting == null) { - oGuildSetting = new OGuildSetting(); - oGuildSetting.setGuild(oGuild); - oGuildSetting.setName(setting.getName()); - oGuildSetting.setValue(value); - - session.persist(oGuildSetting); - } else { - oGuildSetting.setValue(value); - - session.merge(oGuildSetting); - } + existing.forEach(guildSetting -> guildSetting.setValue(value)); } }); } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java index 2403622..d4f2651 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java @@ -1,105 +1,104 @@ package me.bobert.netneutrality.db.repository; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.db.Repository; +import me.bobert.netneutrality.db.AbstractRepository; +import me.bobert.netneutrality.db.SessionProvider; import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.entities.OLogMessage; -import me.bobert.netneutrality.db.JpaUtils; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import org.hibernate.Session; -import org.hibernate.query.Query; -import java.util.ArrayList; +import java.time.Instant; import java.util.List; +import java.util.Optional; -public class MessageRepository implements Repository { - @Override - public List findAll() { - List messages = new ArrayList<>(); +public class MessageRepository extends AbstractRepository { - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("FROM OLogMessage", OLogMessage.class); - - messages.addAll(query.list()); - }); - - return messages; + public MessageRepository(SessionProvider sessions) { + super(sessions, OLogMessage.class); } - @Override - public OLogMessage findById(String id) { - try (Session session = JpaUtils.openSession()) { - Query query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); - query.setParameter("messageId", id); - - return query.uniqueResult(); - } - } - - @Override - public void deleteBy(String id) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); - query.setParameter("messageId", id); - - OLogMessage oLogMessage = query.uniqueResult(); - if (oLogMessage != null) { - session.remove(oLogMessage); - } - }); - } - - //TODO: move to a service - public void log(OGuild oGuild, MessageChannel channel, Member author, Message message) { - JpaUtils.runWithTransaction(session -> { + /** + * Stores a logged message. + * + * @param guildId the guild id (the guild row must exist) + * @param channelId the channel id + * @param userId the author id + * @param messageId the Discord message id + * @param content the raw message content + * @param sentAt when the message was sent + * @return the persistent log entry + */ + public OLogMessage log(String guildId, String channelId, String userId, String messageId, String content, Instant sentAt) { + return write(session -> { OLogMessage logMessage = new OLogMessage(); - logMessage.setGuild(oGuild); - logMessage.setMessageId(message.getId()); - logMessage.setMessage(message.getContentRaw().trim()); - logMessage.setChannelId(channel.getId()); - logMessage.setUserId(author.getId()); - logMessage.setDateSent(message.getTimeCreated().toInstant()); - + logMessage.setGuild(session.getReference(OGuild.class, guildId)); + logMessage.setChannelId(channelId); + logMessage.setUserId(userId); + logMessage.setMessageId(messageId); + logMessage.setMessage(content == null ? null : content.trim()); + logMessage.setDateSent(sentAt); session.persist(logMessage); + return logMessage; }); } - public void logEdit(String messageId, String newMessage) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); - query.setParameter("messageId", messageId); + public Optional findByMessageId(String messageId) { + return read(session -> findByMessageId(session, messageId)); + } - OLogMessage message = query.uniqueResult(); - if (message != null) { + public Optional findMessageContent(String messageId) { + return findByMessageId(messageId).map(OLogMessage::getMessage); + } + + /** + * Records the edited content of a logged message. + * + * @param messageId the Discord message id + * @param newMessage the edited content + * @return {@code true} if the message was found and updated + */ + public boolean updateEditedMessage(String messageId, String newMessage) { + return write(session -> findByMessageId(session, messageId) + .map(message -> { message.setEditedMessage(newMessage); - - session.merge(message); - } - }); + return true; + }) + .orElse(false)); } - public List getMessagesFromUser(String userId, String guildId, int max) { - List messages = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId", OLogMessage.class); - query.setParameter("guildId", guildId); - query.setParameter("userId", userId); - query.setMaxResults(max); - - messages.addAll(query.list()); - }); - - return messages; + public boolean deleteByMessageId(String messageId) { + return write(session -> session + .createMutationQuery("DELETE FROM OLogMessage o WHERE o.messageId = :messageId") + .setParameter("messageId", messageId) + .executeUpdate() > 0); } - public String getMessageString(String messageId) { - return findById(messageId).getMessage(); + /** + * Returns the most recent messages of a user in a guild, newest first. + * + * @param userId the user id + * @param guildId the guild id + * @param max the maximum number of messages + * @return the messages + */ + public List findByUser(String userId, String guildId, int max) { + return read(session -> session + .createSelectionQuery("FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId ORDER BY o.dateSent DESC", OLogMessage.class) + .setParameter("guildId", guildId) + .setParameter("userId", userId) + .setMaxResults(max) + .getResultList()); + } + + private static Optional findByMessageId(Session session, String messageId) { + return session + .createSelectionQuery("FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class) + .setParameter("messageId", messageId) + .uniqueResultOptional(); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java index d4acd36..337af8b 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java @@ -1,55 +1,63 @@ package me.bobert.netneutrality.db.repository; + /* * Copyright (c) anthorob * * https://discord.gg/qUa6zNFZsd */ - -import me.bobert.netneutrality.db.Repository; +import me.bobert.netneutrality.db.AbstractRepository; +import me.bobert.netneutrality.db.SessionProvider; import me.bobert.netneutrality.db.entities.OTask; -import me.bobert.netneutrality.db.JpaUtils; import org.hibernate.Session; -import org.hibernate.query.Query; -import java.util.ArrayList; -import java.util.List; +import java.util.Optional; -public class TaskRepository implements Repository { +public class TaskRepository extends AbstractRepository { - @Override - public List findAll() { - List tasks = new ArrayList<>(); - - JpaUtils.runWithSession(session -> { - Query query = session.createQuery("FROM OTask", OTask.class); - - tasks.addAll(query.list()); - }); - - return tasks; + public TaskRepository(SessionProvider sessions) { + super(sessions, OTask.class); } - @Override - public OTask findById(String id) { - try (Session session = JpaUtils.openSession()) { - Query query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class); - query.setParameter("taskId", id); - - return query.uniqueResult(); - } + public Optional findByTaskId(String taskId) { + return read(session -> findByTaskId(session, taskId)); } - @Override - public void deleteBy(String id) { - JpaUtils.runWithTransaction(session -> { - Query query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class); - query.setParameter("taskId", id); - - OTask oTask = query.uniqueResult(); - if (oTask != null) { - session.remove(oTask); + /** + * Inserts the task or updates its payload, in a single transaction. + * + * @param taskId the logical task id + * @param typeId the persistable task type id + * @param payload the serialized payload + * @return the persistent task + */ + public OTask upsert(String taskId, String typeId, String payload) { + return write(session -> { + OTask task = findByTaskId(session, taskId).orElse(null); + if (task == null) { + task = new OTask(); + task.setTaskId(taskId); + task.setTaskClass(typeId); + task.setPayload(payload); + session.persist(task); + } else { + task.setPayload(payload); } + return task; }); } + + public boolean deleteByTaskId(String taskId) { + return write(session -> session + .createMutationQuery("DELETE FROM OTask o WHERE o.taskId = :taskId") + .setParameter("taskId", taskId) + .executeUpdate() > 0); + } + + private static Optional findByTaskId(Session session, String taskId) { + return session + .createSelectionQuery("FROM OTask o WHERE o.taskId = :taskId", OTask.class) + .setParameter("taskId", taskId) + .uniqueResultOptional(); + } } diff --git a/src/main/java/me/bobert/netneutrality/db/service/BlacklistEntry.java b/src/main/java/me/bobert/netneutrality/db/service/BlacklistEntry.java new file mode 100644 index 0000000..de6187f --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/service/BlacklistEntry.java @@ -0,0 +1,16 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * Read-only view of a blacklisted word. + * + * @param word the word + * @param enabled whether the word is currently enforced + */ +public record BlacklistEntry(String word, boolean enabled) { +} diff --git a/src/main/java/me/bobert/netneutrality/db/service/BlacklistResult.java b/src/main/java/me/bobert/netneutrality/db/service/BlacklistResult.java new file mode 100644 index 0000000..fb4fa9c --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/service/BlacklistResult.java @@ -0,0 +1,23 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * Outcome of a blacklist change made through {@link BlacklistService}. + */ +public enum BlacklistResult { + /** The word was added to the blacklist. */ + ADDED, + /** The word existed but was disabled, and is now enabled again. */ + REENABLED, + /** The word was already blacklisted; nothing changed. */ + ALREADY_EXISTS, + /** The word was removed from the blacklist. */ + REMOVED, + /** The word was not blacklisted; nothing changed. */ + NOT_FOUND +} diff --git a/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java b/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java index d64538f..cdd06e1 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/BlacklistService.java @@ -6,93 +6,108 @@ package me.bobert.netneutrality.db.service; * https://discord.gg/qUa6zNFZsd */ -import me.bobert.netneutrality.core.command.CommandExecutionResult; -import me.bobert.netneutrality.db.entities.OBlacklist; -import me.bobert.netneutrality.db.entities.OGuild; import me.bobert.netneutrality.db.repository.BlacklistRepository; -import me.bobert.netneutrality.db.JpaUtils; -import net.dv8tion.jda.api.entities.Member; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import java.util.Collections; import java.util.List; -import java.util.Map; +import java.util.Locale; +import java.util.Objects; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; import java.util.stream.Collectors; +/** + * Per-guild blacklist, backed by the {@code blacklist} table and an in-memory cache of the enabled + * words. Persistence failures propagate to the caller. + */ public class BlacklistService { - private static final Logger logger = LoggerFactory.getLogger(BlacklistService.class); - private static final Map> blacklistCache = new ConcurrentHashMap<>(); + private static final Logger logger = LogManager.getLogger(BlacklistService.class); + private static final Pattern WORD_SEPARATOR = Pattern.compile("[^\\p{L}\\p{N}]+"); + private final BlacklistRepository blacklistRepository; private final GuildService guildService; + private volatile Set enabledWords = Set.of(); - public BlacklistService(GuildService guildService) { - this.blacklistRepository = new BlacklistRepository(); - this.guildService = guildService; - init(); - } - - private void init() { + public BlacklistService(GuildService guildService, BlacklistRepository blacklistRepository) { + this.guildService = Objects.requireNonNull(guildService, "guildService"); + this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository"); reloadBlacklistCache(); } public void reloadBlacklistCache() { - OGuild oGuild = guildService.getEntity(); - logger.debug("Reloading blacklist cache for guild: {}", oGuild.getGuildId()); - - JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> { - Set blacklistWords = oGuild.getBlacklists().stream() - .filter(OBlacklist::getEnabled) - .map(OBlacklist::getWord) - .collect(Collectors.toSet()); - - blacklistCache.put(oGuild.getGuildId(), blacklistWords); - }); + logger.debug("Reloading blacklist cache for guild: {}", guildService.getGuildId()); + enabledWords = blacklistRepository.findEnabledWords(guildService.getGuildId()).stream() + .map(BlacklistService::normalize) + .collect(Collectors.toUnmodifiableSet()); } - public CommandExecutionResult add(Member member, String word) { - try { - blacklistRepository.add(guildService.getEntity(), member, word); - reloadBlacklistCache(); + /** + * Adds the word to the blacklist, or re-enables it. + * + * @param creatorUserId the id of the user adding the word + * @param word the word + * @return {@link BlacklistResult#ADDED}, {@link BlacklistResult#REENABLED} or + * {@link BlacklistResult#ALREADY_EXISTS} + */ + public BlacklistResult add(String creatorUserId, String word) { + guildService.ensureEntity(); + BlacklistRepository.EnableOutcome outcome = + blacklistRepository.enableWord(guildService.getGuildId(), creatorUserId, word); + reloadBlacklistCache(); - return CommandExecutionResult.success("Added the word [%s] to the blacklist.", word); - } catch (Exception e) { - return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage()); - } + return switch (outcome) { + case CREATED -> BlacklistResult.ADDED; + case ENABLED -> BlacklistResult.REENABLED; + case UNCHANGED -> BlacklistResult.ALREADY_EXISTS; + }; } - public CommandExecutionResult delete(String word) { - try { - blacklistRepository.delete(guildService.getEntity(), word); - reloadBlacklistCache(); + /** + * Removes the word from the blacklist. + * + * @param word the word + * @return {@link BlacklistResult#REMOVED} or {@link BlacklistResult#NOT_FOUND} + */ + public BlacklistResult remove(String word) { + boolean deleted = blacklistRepository.deleteWord(guildService.getGuildId(), word); + reloadBlacklistCache(); - return CommandExecutionResult.success("Removed the word [%s] to the blacklist.", word); - } catch (Exception e) { - return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage()); - } + return deleted ? BlacklistResult.REMOVED : BlacklistResult.NOT_FOUND; } - public List getBlacklistForGuild() { - try { - return blacklistRepository.findByGuildId(guildService.getEntity().getGuildId()); - } catch (Exception e) { - return List.of(); + public List list() { + return blacklistRepository.findByGuildId(guildService.getGuildId()).stream() + .map(blacklist -> new BlacklistEntry(blacklist.getWord(), Boolean.TRUE.equals(blacklist.getEnabled()))) + .toList(); + } + + /** + * Returns whether any word of the text is an enabled blacklisted word (case-insensitive). + * + * @param text the text to check + * @return {@code true} if the text contains a blacklisted word + */ + public boolean containsBlacklistedWord(String text) { + Set words = enabledWords; + if (text == null || words.isEmpty()) { + return false; } + + for (String token : WORD_SEPARATOR.split(text)) { + if (!token.isEmpty() && words.contains(normalize(token))) { + return true; + } + } + return false; } public void removeGuildBlacklist() { - logger.info("Clearing blacklist cache for guild: {}", guildService.getEntity().getGuildId()); - blacklistCache.remove(guildService.getEntity().getGuildId()); + logger.info("Clearing blacklist cache for guild: {}", guildService.getGuildId()); + enabledWords = Set.of(); } - public static boolean isWordBlacklisted(String guildId, String word) { - Set blacklistWords = blacklistCache.get(guildId); - return blacklistWords != null && blacklistWords.contains(word); - } - - public static Set getBlacklist(String guildId) { - return blacklistCache.getOrDefault(guildId, Collections.emptySet()); + private static String normalize(String word) { + return word.toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java index f36cfca..43aea71 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java @@ -7,10 +7,13 @@ package me.bobert.netneutrality.db.service; */ import me.bobert.netneutrality.db.entities.OGuild; +import me.bobert.netneutrality.db.repository.BlacklistRepository; import me.bobert.netneutrality.db.repository.GuildRepository; -import me.bobert.netneutrality.db.JpaUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Objects; /** * Per-guild domain service (blacklist, settings, entity access). @@ -19,16 +22,24 @@ import org.slf4j.LoggerFactory; * {@link me.bobert.netneutrality.db.service.GuildServiceProvider}. */ public class GuildService { - private static final Logger logger = LoggerFactory.getLogger(GuildService.class); - private final GuildRepository guildRepository = new GuildRepository(); + private static final Logger logger = LogManager.getLogger(GuildService.class); + private final String guildId; + private final GuildRepository guildRepository; private final BlacklistService blacklistService; private final GuildSettingService guildSettingService; - private OGuild oGuild; + private volatile OGuild oGuild; - GuildService(String guildId) { + public GuildService(String guildId, GuildRepository guildRepository, BlacklistRepository blacklistRepository, + GuildSettingRepository guildSettingRepository) { + this.guildId = Objects.requireNonNull(guildId, "guildId"); + this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository"); this.oGuild = findOrCreateGuild(guildId); - this.blacklistService = new BlacklistService(this); - this.guildSettingService = new GuildSettingService(this); + this.blacklistService = new BlacklistService(this, blacklistRepository); + this.guildSettingService = new GuildSettingService(this, guildSettingRepository); + } + + public String getGuildId() { + return guildId; } public OGuild getEntity() { @@ -43,17 +54,23 @@ public class GuildService { return guildSettingService; } + /** + * Returns the guild row, creating it when it does not exist (for example after it was deleted + * while the service was cached). + * + * @param guildId the guild id + * @return the persistent guild + */ public OGuild findOrCreateGuild(String guildId) { - OGuild guildEntity = guildRepository.findById(guildId); - if (guildEntity == null) { - logger.debug("Guild[{}] doesn't exist in DB, creating entity..", guildId); + logger.debug("Loading Guild[{}]", guildId); + return guildRepository.findOrCreate(guildId); + } - guildEntity = new OGuild(); - guildEntity.setGuildId(guildId); - guildRepository.save(guildEntity); - } - - return guildEntity; + /** + * Makes sure the guild row exists before writing rows that reference it. + */ + void ensureEntity() { + this.oGuild = findOrCreateGuild(guildId); } public boolean isDebugEnabled() { @@ -61,14 +78,17 @@ public class GuildService { } public void updateDebugMode(boolean debug) { - JpaUtils.updateWithTransaction(oGuild, () -> oGuild.setDebug(debug)); + OGuild current = findOrCreateGuild(guildId); + current.setDebug(debug); + this.oGuild = guildRepository.save(current); } public void deleteGuild() { - guildRepository.deleteBy(oGuild.getGuildId()); + guildRepository.deleteById(guildId); + blacklistService.removeGuildBlacklist(); } public void reloadEntity() { - this.oGuild = findOrCreateGuild(oGuild.getGuildId()); + ensureEntity(); } } diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java index 9434d50..59466bc 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProvider.java @@ -6,8 +6,6 @@ package me.bobert.netneutrality.db.service; * https://discord.gg/qUa6zNFZsd */ -import net.dv8tion.jda.api.entities.Guild; - import java.util.Map; /** @@ -24,17 +22,16 @@ public interface GuildServiceProvider { GuildService get(String guildId); /** - * Returns (creating on first use) the service for the given guild. + * Drops the cached service for the guild; the next {@link #get(String)} builds a new one. * - * @param guild the Discord guild - * @return the guild service + * @param guildId the Discord guild id */ - GuildService get(Guild guild); + void evict(String guildId); /** - * Returns the live cache of guild services. + * Returns a read-only snapshot of the cached guild services. * * @return the services keyed by guild id */ - Map getServices(); + Map snapshot(); } diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java index 3e36687..21508ce 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildServiceProviderImpl.java @@ -6,9 +6,12 @@ package me.bobert.netneutrality.db.service; * https://discord.gg/qUa6zNFZsd */ -import net.dv8tion.jda.api.entities.Guild; +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** @@ -17,22 +20,30 @@ import java.util.concurrent.ConcurrentHashMap; public class GuildServiceProviderImpl implements GuildServiceProvider { private final Map services = new ConcurrentHashMap<>(); + private final GuildRepository guildRepository; + private final BlacklistRepository blacklistRepository; + private final GuildSettingRepository guildSettingRepository; - public GuildServiceProviderImpl() { + public GuildServiceProviderImpl(GuildRepository guildRepository, BlacklistRepository blacklistRepository, + GuildSettingRepository guildSettingRepository) { + this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository"); + this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository"); + this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository"); } @Override public GuildService get(String guildId) { - return services.computeIfAbsent(guildId, GuildService::new); + return services.computeIfAbsent(guildId, + id -> new GuildService(id, guildRepository, blacklistRepository, guildSettingRepository)); } @Override - public GuildService get(Guild guild) { - return get(guild.getId()); + public void evict(String guildId) { + services.remove(guildId); } @Override - public Map getServices() { - return services; + public Map snapshot() { + return Map.copyOf(services); } } diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildSettingService.java b/src/main/java/me/bobert/netneutrality/db/service/GuildSettingService.java index e71683f..0cf5d3c 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildSettingService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildSettingService.java @@ -9,52 +9,73 @@ package me.bobert.netneutrality.db.service; import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.entities.OGuildSetting; import me.bobert.netneutrality.db.repository.GuildSettingRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import java.util.List; +import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Objects; +import java.util.Optional; +/** + * Per-guild settings, backed by the {@code guild_settings} table. Only non-default values are + * stored; reads come from an immutable snapshot that is swapped atomically on reload. + */ public class GuildSettingService { - private static final Logger logger = LoggerFactory.getLogger(GuildSettingService.class); - private final GuildSettingRepository guildSettingRepository = new GuildSettingRepository(); + private static final Logger logger = LogManager.getLogger(GuildSettingService.class); + private final GuildSettingRepository guildSettingRepository; private final GuildService guildService; - private final Map settingsCache = new ConcurrentHashMap<>(); + private volatile Map settingsCache = Map.of(); - public GuildSettingService(GuildService guildService) { - this.guildService = guildService; + public GuildSettingService(GuildService guildService, GuildSettingRepository guildSettingRepository) { + this.guildService = Objects.requireNonNull(guildService, "guildService"); + this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository"); reloadSettingsCache(); } - public Map getSettings() { + /** + * @return an unmodifiable snapshot of the stored (non-default) settings, keyed by setting name + */ + public Map snapshot() { return settingsCache; } - public String getSetting(GSettings setting) { - String value = settingsCache.get(setting.getName()); - - if (value == null) { - value = setting.getDefaultValue(); - } - - return value; + /** + * @param key the setting + * @return the stored value, or empty when the setting uses its default + */ + public Optional get(GSettings key) { + return Optional.ofNullable(settingsCache.get(key.getName())); } - public void setSettingValue(GSettings setting, String value) { - guildSettingRepository.setSettingValue(guildService.getEntity(), setting, value); + /** + * @param key the setting + * @return the stored value, or the setting's default + */ + public String getOrDefault(GSettings key) { + return get(key).orElse(key.getDefaultValue()); + } + + /** + * Stores the value; {@code null} or the default value deletes the stored setting. + * + * @param key the setting + * @param value the new value, or {@code null} to reset it + */ + public void set(GSettings key, String value) { + guildService.ensureEntity(); + guildSettingRepository.upsertOrDelete(guildService.getGuildId(), key, value); reloadSettingsCache(); } public void reloadSettingsCache() { - logger.debug("Reloading settings cache for guild: {}", guildService.getEntity().getGuildId()); - settingsCache.clear(); + logger.debug("Reloading settings cache for guild: {}", guildService.getGuildId()); - - List settings = guildSettingRepository.findByGuild(guildService.getEntity().getGuildId()); - for (OGuildSetting setting : settings) { - settingsCache.put(setting.getName(), setting.getValue()); + Map fresh = new HashMap<>(); + for (OGuildSetting setting : guildSettingRepository.findByGuild(guildService.getGuildId())) { + fresh.put(setting.getName(), setting.getValue()); } + settingsCache = Map.copyOf(fresh); } } diff --git a/src/main/java/me/bobert/netneutrality/db/service/TaskService.java b/src/main/java/me/bobert/netneutrality/db/service/TaskService.java index 4734426..1ee5469 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/TaskService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/TaskService.java @@ -24,10 +24,10 @@ public class TaskService { private final TaskRepository taskRepository; private final PersistableTaskRegistry registry; - public TaskService(TaskManager taskManager, PersistableTaskRegistry registry) { - this.taskManager = taskManager; - this.taskRepository = new TaskRepository(); - this.registry = registry; + public TaskService(TaskManager taskManager, PersistableTaskRegistry registry, TaskRepository taskRepository) { + this.taskManager = Objects.requireNonNull(taskManager, "taskManager"); + this.registry = Objects.requireNonNull(registry, "registry"); + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository"); } public void loadTasks() { @@ -40,13 +40,11 @@ public class TaskService { String typeId = oTask.getTaskClass(); logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId); - String payload = oTask.getPayload(); - try { - ObjectNode json = Json.readObject(payload); + ObjectNode json = Json.readObject(oTask.getPayload()); PersistableTask task = registry.create(typeId, json); - validateTask(task, oTask); + validateTask(task, taskId, json); taskManager.insertPersistableTask(task); } catch (Exception e) { logger.error("Error loading task with ID {}: {}", taskId, e.getMessage()); @@ -54,15 +52,11 @@ public class TaskService { } } - private void validateTask(PersistableTask task, OTask oTask) throws IllegalArgumentException { - String taskId = oTask.getTaskId(); - String payload = oTask.getPayload(); - + private static void validateTask(PersistableTask task, String taskId, ObjectNode json) throws IllegalArgumentException { if (!Objects.equals(taskId, task.getId())) { throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId()); } - ObjectNode json = Json.readObject(payload); String payloadId = json.path("id").asText(null); if (!Objects.equals(payloadId, task.getId())) { throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId()); @@ -81,24 +75,12 @@ public class TaskService { return; } - TaskRepository taskRepository = new TaskRepository(); - OTask oTask = taskRepository.findById(task.getId()); - if (oTask == null) { - oTask = new OTask(); - oTask.setTaskId(task.getId()); - oTask.setTaskClass(typeId); - } - - oTask.setPayload(task.toPayload()); - - logger.info("Persisting task: {}", oTask.getTaskId()); - taskRepository.save(oTask); + logger.info("Persisting task: {}", task.getId()); + taskRepository.upsert(task.getId(), typeId, task.toPayload()); } public void unpersistTask(PersistableTask task) { logger.debug("Unpersisting task: {}", task.getId()); - TaskRepository taskRepository = new TaskRepository(); - - taskRepository.deleteBy(task.getId()); + taskRepository.deleteByTaskId(task.getId()); } } diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java index 7c13a12..f981071 100644 --- a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java +++ b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java @@ -95,13 +95,29 @@ public class EventsHandler implements EventListener { addListener(ButtonInteractionEvent.class, ButtonTaskContext::new); addListener(MessageReactionAddEvent.class, EmojiTaskContext::new); addGenericListener(GuildJoinEvent.class, event -> { - GuildService service = guildServices.get(event.getGuild()); + GuildService service = guildServices.get(event.getGuild().getId()); if (service.isDebugEnabled()) { LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel"); } }); - addGenericListener(GuildLeaveEvent.class, event -> guildServices.get(event.getGuild()).deleteGuild()); + addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId())); + } + + /** + * Deletes the guild's data and drops its cached service. The service is evicted even when the + * delete fails, so a stale cache entry never outlives the guild membership. + * + * @param guildId the id of the guild the bot left + */ + void onGuildLeave(String guildId) { + try { + guildServices.get(guildId).deleteGuild(); + } catch (RuntimeException e) { + logger.error("Failed to delete data of guild {}", guildId, e); + } finally { + guildServices.evict(guildId); + } } /** diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java index 7c7ef4c..80ce212 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java @@ -52,14 +52,14 @@ public final class ActionRoutes { routes.get("/actions/settings/current/{guildId}", ctx -> { Guild guild = GuildRoutes.guild(jdaSupplier, ctx); - Map settings = guildServices.get(guild).getGuildSettingService().getSettings(); + Map settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot(); ctx.json(Map.of("status", "ok", "settings", settings)); }); routes.post("/actions/settings/reload/{guildId}", ctx -> { Guild guild = GuildRoutes.guild(jdaSupplier, ctx); - guildServices.get(guild).getGuildSettingService().reloadSettingsCache(); + guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache(); ctx.json(Map.of("status", "true")); }); diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index 424dcca..18eceb0 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -259,7 +259,7 @@ public class MusicManager implements MusicManagerCallback { private static int parseBaseVolume(GuildService guildService) { try { - return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME)); + return Integer.parseInt(guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOLUME)); } catch (NumberFormatException e) { logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e); return DEFAULT_VOLUME; diff --git a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java index b402cec..f149150 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java @@ -105,7 +105,7 @@ public class MusicServiceImpl implements MusicService { public MusicManager get(Guild guild) { return managers.computeIfAbsent( guild.getId(), - id -> new MusicManager(guild, client, guildServices.get(guild), taskManager) + id -> new MusicManager(guild, client, guildServices.get(id), taskManager) ); } diff --git a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java index ca57f55..52e8e3a 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/music/LoadPlaylistTask.java @@ -63,7 +63,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta int searchResultsLimit = 5; // Max number of buttons per row int maxPlaylistSizeBeforeAsking = Integer.parseInt(musicManager.getGuildService() .getGuildSettingService() - .getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE)); + .getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE)); // Search results if (searchResult) { diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml index 955a01e..804cba8 100644 --- a/src/main/resources/hibernate.cfg.xml +++ b/src/main/resources/hibernate.cfg.xml @@ -7,6 +7,8 @@ org.hibernate.dialect.MySQLDialect com.mysql.cj.jdbc.Driver org.hibernate.hikaricp.internal.HikariCPConnectionProvider + + validate @@ -15,6 +17,7 @@ + - \ No newline at end of file + diff --git a/src/test/java/me/bobert/netneutrality/command/admin/BlacklistCommandTest.java b/src/test/java/me/bobert/netneutrality/command/admin/BlacklistCommandTest.java new file mode 100644 index 0000000..3c988d7 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/command/admin/BlacklistCommandTest.java @@ -0,0 +1,100 @@ +package me.bobert.netneutrality.command.admin; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.CommandExecutionResult; +import me.bobert.netneutrality.core.command.CommandExecutionResult.Result; +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.db.service.BlacklistResult; +import me.bobert.netneutrality.db.service.BlacklistService; +import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BlacklistCommandTest { + + private BlacklistService blacklist; + private CommandContext context; + private BlacklistCommand command; + + @BeforeEach + void setUp() { + blacklist = mock(BlacklistService.class); + GuildService guildService = mock(GuildService.class); + when(guildService.getBlacklistService()).thenReturn(blacklist); + GuildServiceProvider provider = mock(GuildServiceProvider.class); + when(provider.get("guild-1")).thenReturn(guildService); + + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild-1"); + Member member = mock(Member.class); + when(member.getId()).thenReturn("user-1"); + + context = mock(CommandContext.class); + when(context.getGuild()).thenReturn(guild); + when(context.getGuildUser()).thenReturn(member); + when(context.getOption(eq("word"), eq(""), any(Function.class))).thenReturn("foo"); + + command = new BlacklistCommand(provider); + } + + @ParameterizedTest + @CsvSource({ + "ADDED, SUCCESS", + "REENABLED, SUCCESS", + "ALREADY_EXISTS, WARN", + "REMOVED, SUCCESS", + "NOT_FOUND, WARN" + }) + void mapsEveryResult(BlacklistResult result, Result expected) { + CommandExecutionResult reply = BlacklistCommand.toReply(result, "foo"); + + assertEquals(expected, reply.getResult()); + assertTrue(reply.getMessage().contains("[foo]")); + } + + @Test + void addPassesPlainIds() { + when(context.getSubcommand()).thenReturn("add"); + when(blacklist.add("user-1", "foo")).thenReturn(BlacklistResult.ADDED); + + assertEquals(Result.SUCCESS, command.execute(context).getResult()); + } + + @Test + void removeNotFoundWarns() { + when(context.getSubcommand()).thenReturn("remove"); + when(blacklist.remove("foo")).thenReturn(BlacklistResult.NOT_FOUND); + + assertEquals(Result.WARN, command.execute(context).getResult()); + } + + @Test + void persistenceFailureBecomesWarning() { + when(context.getSubcommand()).thenReturn("add"); + when(blacklist.add("user-1", "foo")).thenThrow(new IllegalStateException("db down")); + + CommandExecutionResult reply = command.execute(context); + + assertEquals(Result.WARN, reply.getResult()); + assertTrue(reply.getMessage().contains("db down")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java b/src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java index 0fcd9c7..b9c58ad 100644 --- a/src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java +++ b/src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java @@ -16,6 +16,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable; import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithParent; +import me.bobert.netneutrality.db.repository.TaskRepository; import me.bobert.netneutrality.db.service.TaskService; import me.bobert.netneutrality.scheduled.cleanup.TaskCleanup; import net.dv8tion.jda.api.components.buttons.Button; @@ -61,7 +62,7 @@ class TaskManagerImplTest { @BeforeEach void setUp() throws Exception { - taskManager = new TaskManagerImpl(applicationContext); + taskManager = new TaskManagerImpl(applicationContext, mock(TaskRepository.class)); Field taskServiceField = TaskManagerImpl.class.getDeclaredField("taskService"); taskServiceField.setAccessible(true); diff --git a/src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java new file mode 100644 index 0000000..f773527 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java @@ -0,0 +1,105 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.entities.OBlacklist; +import me.bobert.netneutrality.db.entities.OGuild; +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractRepositoryTest extends MySqlTestBase { + + private GuildRepository guilds; + private BlacklistRepository blacklist; + + @BeforeEach + void setUp() { + guilds = new GuildRepository(sessions()); + blacklist = new BlacklistRepository(sessions()); + } + + @Test + void stringIdsFindAndDelete() { + guilds.findOrCreate("guild-1"); + + assertTrue(guilds.findById("guild-1").isPresent()); + assertEquals(1, guilds.findAll().size()); + assertTrue(guilds.deleteById("guild-1")); + assertTrue(guilds.findById("guild-1").isEmpty()); + assertFalse(guilds.deleteById("guild-1")); + } + + @Test + void integerIdsFindAndDelete() { + guilds.findOrCreate("guild-1"); + blacklist.enableWord("guild-1", "user-1", "foo"); + Integer id = blacklist.findByGuildId("guild-1").getFirst().getId(); + + assertEquals("foo", blacklist.findById(id).orElseThrow().getWord()); + assertTrue(blacklist.deleteById(id)); + assertTrue(blacklist.findById(id).isEmpty()); + } + + @Test + void saveReturnsMergedState() { + OGuild guild = guilds.findOrCreate("guild-1"); + guild.setDebug(false); + + OGuild saved = guilds.save(guild); + + assertFalse(saved.getDebug()); + assertFalse(guilds.findById("guild-1").orElseThrow().getDebug()); + } + + @Test + void failingWriteThrowsAndRollsBack() { + RuntimeException failure = new IllegalStateException("boom"); + FailingGuildRepository failing = new FailingGuildRepository(sessions()); + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> failing.insertThenFail("guild-1", failure)); + + assertEquals(failure, thrown); + assertTrue(guilds.findById("guild-1").isEmpty()); + } + + @Test + void constraintViolationPropagates() { + OGuild missing = new OGuild(); + missing.setGuildId("does-not-exist"); + OBlacklist orphan = new OBlacklist(); + orphan.setGuild(missing); + orphan.setWord("foo"); + orphan.setCreatorId("user-1"); + orphan.setEnabled(true); + + assertThrows(RuntimeException.class, () -> blacklist.save(orphan)); + assertTrue(blacklist.findAll().isEmpty()); + } + + private static final class FailingGuildRepository extends AbstractRepository { + FailingGuildRepository(SessionProvider sessions) { + super(sessions, OGuild.class); + } + + void insertThenFail(String guildId, RuntimeException failure) { + writeVoid(session -> { + OGuild guild = new OGuild(); + guild.setGuildId(guildId); + session.persist(guild); + session.flush(); + throw failure; + }); + } + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/DbLayeringTest.java b/src/test/java/me/bobert/netneutrality/db/DbLayeringTest.java new file mode 100644 index 0000000..5484415 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/DbLayeringTest.java @@ -0,0 +1,61 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Keeps the persistence layer free of Discord (JDA) and command-framework types. + */ +class DbLayeringTest { + + private static final Path DB_SOURCES = Path.of("src/main/java/me/bobert/netneutrality/db"); + private static final List FORBIDDEN = List.of( + "import net.dv8tion.jda.", + "import me.bobert.netneutrality.core.command." + ); + + @Test + void servicesAndRepositoriesDoNotImportJdaOrCommandTypes() throws IOException { + List violations; + try (Stream files = Stream.concat(Files.walk(DB_SOURCES.resolve("service")), Files.walk(DB_SOURCES.resolve("repository")))) { + violations = files + .filter(path -> path.toString().endsWith(".java")) + .flatMap(DbLayeringTest::violations) + .toList(); + } + + assertEquals(List.of(), violations); + } + + @Test + void scanFindsSources() throws IOException { + try (Stream files = Files.walk(DB_SOURCES.resolve("service"))) { + assertFalse(files.filter(path -> path.toString().endsWith(".java")).toList().isEmpty()); + } + } + + private static Stream violations(Path file) { + try { + return Files.readAllLines(file).stream() + .map(String::trim) + .filter(line -> FORBIDDEN.stream().anyMatch(line::startsWith)) + .map(line -> file.getFileName() + ": " + line); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/MySqlTestBase.java b/src/test/java/me/bobert/netneutrality/db/MySqlTestBase.java new file mode 100644 index 0000000..8ba1497 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/MySqlTestBase.java @@ -0,0 +1,69 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mysql.MySQLContainer; + +import java.util.List; + +/** + * Shared base for database integration tests. + * + *

Starts one throwaway MySQL 8 container for the whole test run and a real {@link SessionManager} + * on top of it, so Flyway migrations and Hibernate schema validation run exactly as in production. + * Tests are skipped when Docker is not available. Every table is emptied before each test. + */ +@Testcontainers(disabledWithoutDocker = true) +public abstract class MySqlTestBase { + + private static final List TABLES = List.of( + "blacklist", "guild_settings", "logging", "log_message", "tasks", "web_users", "guilds"); + + private static MySQLContainer mysql; + private static SessionManager sessionManager; + + @BeforeAll + static void startDatabase() { + synchronized (MySqlTestBase.class) { + if (sessionManager != null) { + return; + } + // V1 runs CREATE DATABASE, so connect as root to the real schema name. + mysql = new MySQLContainer("mysql:8.4") + .withDatabaseName("netneutrality") + .withUsername("root") + .withPassword("test"); + mysql.start(); + + SessionManager manager = new SessionManager(databaseConfig(), false); + manager.start(); + sessionManager = manager; + } + } + + protected static DatabaseConfig databaseConfig() { + return new DatabaseConfig(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword()); + } + + protected static SessionManager sessions() { + return sessionManager; + } + + @BeforeEach + void cleanDatabase() { + sessionManager.getSessionFactory().inTransaction(session -> { + session.createNativeMutationQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); + for (String table : TABLES) { + session.createNativeMutationQuery("DELETE FROM `" + table + "`").executeUpdate(); + } + session.createNativeMutationQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + }); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/SessionManagerLifecycleTest.java b/src/test/java/me/bobert/netneutrality/db/SessionManagerLifecycleTest.java new file mode 100644 index 0000000..9ee7b68 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/SessionManagerLifecycleTest.java @@ -0,0 +1,34 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Database-free checks of the {@link SessionManager} / {@link DatabaseConfig} contract. + */ +class SessionManagerLifecycleTest { + + @Test + void getSessionFactoryBeforeStartThrows() { + SessionManager manager = new SessionManager(new DatabaseConfig("jdbc:mysql://localhost/x", "u", "p"), false); + + assertThrows(IllegalStateException.class, manager::getSessionFactory); + assertDoesNotThrow(manager::stop); + } + + @Test + void configNeverPrintsPassword() { + DatabaseConfig config = new DatabaseConfig("jdbc:mysql://localhost/x", "user", "hunter2"); + + assertFalse(config.toString().contains("hunter2")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java b/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java new file mode 100644 index 0000000..a3454c2 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java @@ -0,0 +1,36 @@ +package me.bobert.netneutrality.db; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SessionManagerTest extends MySqlTestBase { + + @Test + void startAppliesMigrationsAndValidatesSchema() { + assertFalse(sessions().getSessionFactory().isClosed()); + + Long applied = sessions().getSessionFactory().fromSession(session -> session + .createNativeQuery("SELECT COUNT(*) FROM flyway_schema_history WHERE success = 1", Long.class) + .getSingleResult()); + assertEquals(2L, applied); + } + + @Test + void startFailsClearlyForUnreachableDatabase() { + SessionManager manager = new SessionManager( + new DatabaseConfig("jdbc:mysql://localhost:1/none?connectTimeout=500", "nobody", "secret"), false); + + IllegalStateException e = assertThrows(IllegalStateException.class, manager::start); + assertEquals("Unable to connect to the database", e.getMessage()); + assertThrows(IllegalStateException.class, manager::getSessionFactory); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/entities/EntityEqualityTest.java b/src/test/java/me/bobert/netneutrality/db/entities/EntityEqualityTest.java new file mode 100644 index 0000000..3c21fb0 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/entities/EntityEqualityTest.java @@ -0,0 +1,60 @@ +package me.bobert.netneutrality.db.entities; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EntityEqualityTest { + + @Test + void sameIdIsEqual() { + OBlacklist a = new OBlacklist(); + a.setId(1); + OBlacklist b = new OBlacklist(); + b.setId(1); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void transientInstancesAreOnlyEqualToThemselves() { + OBlacklist a = new OBlacklist(); + OBlacklist b = new OBlacklist(); + + assertEquals(a, a); + assertNotEquals(a, b); + } + + @Test + void hashIsStableWhenIdIsAssigned() { + OGuildSetting setting = new OGuildSetting(); + Set set = new HashSet<>(); + set.add(setting); + + setting.setId(42); + + assertTrue(set.contains(setting)); + } + + @Test + void differentTypesAreNotEqual() { + OGuild guild = new OGuild(); + guild.setGuildId("1"); + OWebUser user = new OWebUser(); + user.setId("1"); + + assertNotEquals(guild, user); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/repository/BlacklistRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/repository/BlacklistRepositoryTest.java new file mode 100644 index 0000000..2a8aabe --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/repository/BlacklistRepositoryTest.java @@ -0,0 +1,76 @@ +package me.bobert.netneutrality.db.repository; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.entities.OBlacklist; +import me.bobert.netneutrality.db.repository.BlacklistRepository.EnableOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlacklistRepositoryTest extends MySqlTestBase { + private static final String GUILD = "guild-1"; + + private BlacklistRepository blacklist; + private GuildRepository guilds; + + @BeforeEach + void setUp() { + blacklist = new BlacklistRepository(sessions()); + guilds = new GuildRepository(sessions()); + guilds.findOrCreate(GUILD); + } + + @Test + void addThenDeleteRemovesRow() { + assertEquals(EnableOutcome.CREATED, blacklist.enableWord(GUILD, "user-1", "foo")); + assertEquals(Set.of("foo"), blacklist.findEnabledWords(GUILD)); + + // Regression: the delete used to run on a detached entity and silently fail. + assertTrue(blacklist.deleteWord(GUILD, "FOO")); + assertTrue(blacklist.findByGuildId(GUILD).isEmpty()); + assertFalse(blacklist.deleteWord(GUILD, "foo")); + } + + @Test + void addingExistingWordIsUnchanged() { + blacklist.enableWord(GUILD, "user-1", "foo"); + + assertEquals(EnableOutcome.UNCHANGED, blacklist.enableWord(GUILD, "user-2", "Foo")); + assertEquals(1, blacklist.findByGuildId(GUILD).size()); + } + + @Test + void addingDisabledWordReenablesIt() { + OBlacklist disabled = new OBlacklist(); + disabled.setGuild(guilds.findById(GUILD).orElseThrow()); + disabled.setWord("bar"); + disabled.setCreatorId("user-1"); + disabled.setEnabled(false); + blacklist.save(disabled); + assertTrue(blacklist.findEnabledWords(GUILD).isEmpty()); + + assertEquals(EnableOutcome.ENABLED, blacklist.enableWord(GUILD, "user-2", "bar")); + assertEquals(Set.of("bar"), blacklist.findEnabledWords(GUILD)); + assertEquals(1, blacklist.findByGuildId(GUILD).size()); + } + + @Test + void wordsAreScopedPerGuild() { + guilds.findOrCreate("guild-2"); + blacklist.enableWord(GUILD, "user-1", "foo"); + + assertTrue(blacklist.findEnabledWords("guild-2").isEmpty()); + assertFalse(blacklist.deleteWord("guild-2", "foo")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/repository/GuildSettingRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/repository/GuildSettingRepositoryTest.java new file mode 100644 index 0000000..376ecc6 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/repository/GuildSettingRepositoryTest.java @@ -0,0 +1,70 @@ +package me.bobert.netneutrality.db.repository; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.entities.OGuildSetting; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuildSettingRepositoryTest extends MySqlTestBase { + private static final String GUILD = "guild-1"; + + private GuildSettingRepository settings; + + @BeforeEach + void setUp() { + settings = new GuildSettingRepository(sessions()); + new GuildRepository(sessions()).findOrCreate(GUILD); + } + + @Test + void setUpdateAndResetPersist() { + settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, "50"); + assertEquals("50", single().getValue()); + + // Regression: updates used to run without a transaction and were never committed. + settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, "70"); + assertEquals("70", single().getValue()); + + settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, GSettings.MUSIC_VOLUME.getDefaultValue()); + assertTrue(settings.findByGuild(GUILD).isEmpty()); + } + + @Test + void nullValueDeletesSetting() { + settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, "role-1"); + + settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, null); + + assertTrue(settings.findByGuild(GUILD).isEmpty()); + } + + @Test + void settingsAreIndependent() { + settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, "role-1"); + settings.upsertOrDelete(GUILD, GSettings.MUSIC_CHANNEL, "channel-1"); + + settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, null); + + OGuildSetting remaining = single(); + assertEquals(GSettings.MUSIC_CHANNEL.getName(), remaining.getName()); + assertEquals("channel-1", remaining.getValue()); + } + + private OGuildSetting single() { + List all = settings.findByGuild(GUILD); + assertEquals(1, all.size()); + return all.getFirst(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/repository/MessageRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/repository/MessageRepositoryTest.java new file mode 100644 index 0000000..6cbde44 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/repository/MessageRepositoryTest.java @@ -0,0 +1,64 @@ +package me.bobert.netneutrality.db.repository; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.entities.OLogMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageRepositoryTest extends MySqlTestBase { + private static final String GUILD = "guild-1"; + + private MessageRepository messages; + + @BeforeEach + void setUp() { + messages = new MessageRepository(sessions()); + new GuildRepository(sessions()).findOrCreate(GUILD); + } + + @Test + void unknownMessageIsEmpty() { + assertTrue(messages.findByMessageId("nope").isEmpty()); + assertTrue(messages.findMessageContent("nope").isEmpty()); + assertFalse(messages.updateEditedMessage("nope", "edited")); + } + + @Test + void logAndEdit() { + messages.log(GUILD, "channel-1", "user-1", "message-1", " hello ", Instant.now()); + + assertEquals("hello", messages.findMessageContent("message-1").orElseThrow()); + assertTrue(messages.updateEditedMessage("message-1", "hello!")); + assertEquals("hello!", messages.findByMessageId("message-1").orElseThrow().getEditedMessage()); + + assertTrue(messages.deleteByMessageId("message-1")); + assertTrue(messages.findByMessageId("message-1").isEmpty()); + } + + @Test + void findByUserReturnsNewestFirst() { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + messages.log(GUILD, "c", "user-1", "old", "old", now.minusSeconds(60)); + messages.log(GUILD, "c", "user-1", "new", "new", now); + messages.log(GUILD, "c", "user-1", "mid", "mid", now.minusSeconds(30)); + messages.log(GUILD, "c", "user-2", "other", "other", now); + + List ids = messages.findByUser("user-1", GUILD, 2).stream().map(OLogMessage::getMessageId).toList(); + + assertEquals(List.of("new", "mid"), ids); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/repository/TaskRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/repository/TaskRepositoryTest.java new file mode 100644 index 0000000..b2f8230 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/repository/TaskRepositoryTest.java @@ -0,0 +1,46 @@ +package me.bobert.netneutrality.db.repository; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.entities.OTask; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TaskRepositoryTest extends MySqlTestBase { + + private TaskRepository tasks; + + @BeforeEach + void setUp() { + tasks = new TaskRepository(sessions()); + } + + @Test + void upsertInsertsThenUpdates() { + tasks.upsert("task-1", "emoji-reaction-role", "{\"v\":1}"); + tasks.upsert("task-1", "emoji-reaction-role", "{\"v\":2}"); + + assertEquals(1, tasks.findAll().size()); + OTask stored = tasks.findByTaskId("task-1").orElseThrow(); + assertEquals("{\"v\":2}", stored.getPayload()); + assertEquals("emoji-reaction-role", stored.getTaskClass()); + } + + @Test + void deleteByTaskId() { + tasks.upsert("task-1", "type", "{}"); + + assertTrue(tasks.deleteByTaskId("task-1")); + assertTrue(tasks.findByTaskId("task-1").isEmpty()); + assertFalse(tasks.deleteByTaskId("task-1")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/service/BlacklistServiceTest.java b/src/test/java/me/bobert/netneutrality/db/service/BlacklistServiceTest.java new file mode 100644 index 0000000..3b95e2e --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/service/BlacklistServiceTest.java @@ -0,0 +1,54 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlacklistServiceTest extends MySqlTestBase { + + private BlacklistService service; + + @BeforeEach + void setUp() { + GuildService guildService = new GuildService("guild-1", new GuildRepository(sessions()), + new BlacklistRepository(sessions()), new GuildSettingRepository(sessions())); + service = guildService.getBlacklistService(); + } + + @Test + void resultSequence() { + assertEquals(BlacklistResult.ADDED, service.add("user-1", "foo")); + assertEquals(BlacklistResult.ALREADY_EXISTS, service.add("user-1", "foo")); + assertEquals(BlacklistResult.REMOVED, service.remove("foo")); + assertEquals(BlacklistResult.NOT_FOUND, service.remove("foo")); + } + + @Test + void cacheFollowsChanges() { + service.add("user-1", "Foo"); + + assertEquals(List.of(new BlacklistEntry("Foo", true)), service.list()); + assertTrue(service.containsBlacklistedWord("well, FOO bar")); + assertFalse(service.containsBlacklistedWord("foobar")); + + service.remove("foo"); + + assertFalse(service.containsBlacklistedWord("foo")); + assertTrue(service.list().isEmpty()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/service/GuildServiceProviderImplTest.java b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceProviderImplTest.java new file mode 100644 index 0000000..990ffe8 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceProviderImplTest.java @@ -0,0 +1,53 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class GuildServiceProviderImplTest { + + private GuildServiceProviderImpl provider; + + @BeforeEach + void setUp() { + provider = new GuildServiceProviderImpl(mock(GuildRepository.class), mock(BlacklistRepository.class), + mock(GuildSettingRepository.class)); + } + + @Test + void getCachesPerGuild() { + assertSame(provider.get("guild-1"), provider.get("guild-1")); + assertNotSame(provider.get("guild-1"), provider.get("guild-2")); + } + + @Test + void evictBuildsNewServiceOnNextGet() { + GuildService first = provider.get("guild-1"); + + provider.evict("guild-1"); + + assertTrue(provider.snapshot().isEmpty()); + assertNotSame(first, provider.get("guild-1")); + } + + @Test + void snapshotIsReadOnly() { + provider.get("guild-1"); + + assertThrows(UnsupportedOperationException.class, () -> provider.snapshot().clear()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java new file mode 100644 index 0000000..b71b15c --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java @@ -0,0 +1,71 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.MySqlTestBase; +import me.bobert.netneutrality.db.repository.BlacklistRepository; +import me.bobert.netneutrality.db.repository.GuildRepository; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuildServiceTest extends MySqlTestBase { + + private GuildRepository guilds; + private BlacklistRepository blacklist; + private GuildSettingRepository settings; + + @BeforeEach + void setUp() { + guilds = new GuildRepository(sessions()); + blacklist = new BlacklistRepository(sessions()); + settings = new GuildSettingRepository(sessions()); + } + + private GuildService newService(String guildId) { + return new GuildService(guildId, guilds, blacklist, settings); + } + + @Test + void findOrCreateGuildCreatesOnce() { + GuildService service = newService("guild-1"); + + service.findOrCreateGuild("guild-1"); + newService("guild-1"); + + assertEquals(1, guilds.findAll().size()); + assertTrue(service.isDebugEnabled()); + } + + @Test + void updateDebugModePersists() { + GuildService service = newService("guild-1"); + + service.updateDebugMode(false); + + assertFalse(service.isDebugEnabled()); + assertFalse(guilds.findById("guild-1").orElseThrow().getDebug()); + } + + @Test + void writesRecreateDeletedGuild() { + GuildService service = newService("guild-1"); + service.deleteGuild(); + assertTrue(guilds.findById("guild-1").isEmpty()); + + // Replaces the old tryLoad side channel: the cached service recreates its row on write. + service.getGuildSettingService().set(GSettings.MUSIC_VOLUME, "55"); + + assertTrue(guilds.findById("guild-1").isPresent()); + assertEquals("55", service.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOLUME)); + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/service/GuildSettingServiceTest.java b/src/test/java/me/bobert/netneutrality/db/service/GuildSettingServiceTest.java new file mode 100644 index 0000000..44cea13 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/service/GuildSettingServiceTest.java @@ -0,0 +1,99 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.entities.OGuildSetting; +import me.bobert.netneutrality.db.repository.GuildSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class GuildSettingServiceTest { + + private GuildService guildService; + private GuildSettingRepository repository; + + @BeforeEach + void setUp() { + guildService = mock(GuildService.class); + repository = mock(GuildSettingRepository.class); + when(guildService.getGuildId()).thenReturn("guild-1"); + } + + @Test + void readersNeverSeeEmptyCacheDuringReload() throws Exception { + CountDownLatch reloading = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(repository.findByGuild("guild-1")) + .thenReturn(List.of(setting(GSettings.MUSIC_VOLUME, "50"))) + .thenAnswer(invocation -> { + reloading.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return List.of(setting(GSettings.MUSIC_VOLUME, "70")); + }); + GuildSettingService service = new GuildSettingService(guildService, repository); + + CompletableFuture reload = CompletableFuture.runAsync(service::reloadSettingsCache); + assertTrue(reloading.await(5, TimeUnit.SECONDS)); + + // While the reload is in flight, readers still get the previous, complete snapshot. + assertEquals("50", service.getOrDefault(GSettings.MUSIC_VOLUME)); + assertEquals(Map.of("MUSIC_VOLUME", "50"), service.snapshot()); + + release.countDown(); + reload.get(5, TimeUnit.SECONDS); + assertEquals("70", service.getOrDefault(GSettings.MUSIC_VOLUME)); + } + + @Test + void settingsSnapshotIsUnmodifiable() { + when(repository.findByGuild("guild-1")).thenReturn(List.of(setting(GSettings.MUSIC_ROLE, "role-1"))); + GuildSettingService service = new GuildSettingService(guildService, repository); + + assertThrows(UnsupportedOperationException.class, () -> service.snapshot().put("x", "y")); + } + + @Test + void missingSettingFallsBackToDefault() { + when(repository.findByGuild("guild-1")).thenReturn(List.of()); + GuildSettingService service = new GuildSettingService(guildService, repository); + + assertEquals(GSettings.MUSIC_MAXPLAYLISTSIZE.getDefaultValue(), service.getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE)); + assertTrue(service.get(GSettings.MUSIC_MAXPLAYLISTSIZE).isEmpty()); + } + + @Test + void setEnsuresGuildAndWritesThrough() { + when(repository.findByGuild("guild-1")).thenReturn(List.of()); + GuildSettingService service = new GuildSettingService(guildService, repository); + + service.set(GSettings.MUSIC_VOLUME, "40"); + + verify(guildService).ensureEntity(); + verify(repository).upsertOrDelete("guild-1", GSettings.MUSIC_VOLUME, "40"); + } + + private static OGuildSetting setting(GSettings key, String value) { + OGuildSetting setting = new OGuildSetting(); + setting.setName(key.getName()); + setting.setValue(value); + return setting; + } +} diff --git a/src/test/java/me/bobert/netneutrality/db/service/TaskServiceTest.java b/src/test/java/me/bobert/netneutrality/db/service/TaskServiceTest.java new file mode 100644 index 0000000..c2a42ef --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/db/service/TaskServiceTest.java @@ -0,0 +1,63 @@ +package me.bobert.netneutrality.db.service; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.tasks.PersistableTask; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry; +import me.bobert.netneutrality.db.repository.TaskRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class TaskServiceTest { + + private PersistableTaskRegistry registry; + private TaskRepository repository; + private TaskService service; + private PersistableTask task; + + @BeforeEach + void setUp() { + registry = mock(PersistableTaskRegistry.class); + repository = mock(TaskRepository.class); + service = new TaskService(mock(TaskManager.class), registry, repository); + task = mock(PersistableTask.class); + when(task.getId()).thenReturn("task-1"); + when(task.typeId()).thenReturn("some-type"); + } + + @Test + void persistUnregisteredTypeIsNoop() { + when(registry.isRegistered("some-type")).thenReturn(false); + + service.persistTask(task); + + verifyNoInteractions(repository); + } + + @Test + void persistRegisteredTypeUpserts() { + when(registry.isRegistered("some-type")).thenReturn(true); + when(task.toPayload()).thenReturn("{}"); + + service.persistTask(task); + + verify(repository).upsert("task-1", "some-type", "{}"); + } + + @Test + void unpersistDeletesByTaskId() { + service.unpersistTask(task); + + verify(repository).deleteByTaskId("task-1"); + } +} diff --git a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java index 43b8421..14aed1c 100644 --- a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java +++ b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java @@ -58,13 +58,13 @@ class WebApiTest { when(jda.getGatewayPing()).thenReturn(12L); settingService = mock(GuildSettingService.class); - when(settingService.getSettings()).thenReturn(Map.of("prefix", "ü!")); + when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!")); GuildService guildService = mock(GuildService.class); when(guildService.getGuildSettingService()).thenReturn(settingService); GuildServiceProvider provider = mock(GuildServiceProvider.class); - when(provider.get(guild)).thenReturn(guildService); + when(provider.get(GUILD_ID)).thenReturn(guildService); SettingsProvider settings = mock(SettingsProvider.class); when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));