Remove JpaUtils and refactor repository access

Eliminated the static `JpaUtils` utility and transitioned to explicit repository management for improved modularity and testability. Updated `GuildService`, `TaskManagerImpl`, and related classes to use dependency-injected repositories. Simplified configuration by aligning repository definitions with existing service patterns and ensuring proper session handling through `SessionManager`. Verified that all affected components function as expected after refactoring.
This commit is contained in:
anthorob committed 2026-09-25 12:17:19 -04:00
1 parent 62de32ecfc
commit 677b3f8013
60 files changed
+2370 -766

No files matched your search

+309
View File
@@ -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<O>` | 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<T, ID>`.
- 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<T, ID>` 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<T, ID> {
protected final SessionProvider sessions;
private final Class<T> type;
protected AbstractRepository(SessionProvider sessions, Class<T> type)
public List<T> findAll() // fromSession + criteria/HQL
public Optional<T> 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> R read(Function<Session, R> work) // sf.fromSession
protected <R> R write(Function<Session, R> work) // sf.fromTransaction
protected void writeVoid(Consumer<Session> work) // sf.inTransaction
}
```
Repositories and their key queries:
- `GuildRepository extends AbstractRepository<OGuild, String>`.
- `BlacklistRepository<OBlacklist, Integer>`:
- `findByGuildId`
- `findEnabledWords(guildId)` returns `Set<String>`
- `enableWord(OGuild, creatorId, word)`
- `deleteWord(guildId, word)`
- `GuildSettingRepository<OGuildSetting, Integer>`:
- `findByGuild`
- `upsertOrDelete(guildId, name, value, default)`, which queries the setting directly instead of using the lazy collection.
- `TaskRepository<OTask, String>`.
- `MessageRepository<OLogMessage, String>`:
- `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<BlacklistEntry> 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<String> get(GSettings key);
Map<String, String> snapshot(); // unmodifiable
}
public interface GuildServiceProvider {
GuildService get(String guildId);
void evict(String guildId);
Map<String, GuildService> 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<T, ID>`, 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.
+13
View File
@@ -18,6 +18,7 @@
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target> <maven.compiler.target>25</maven.compiler.target>
<mockito.version>5.23.0</mockito.version> <mockito.version>5.23.0</mockito.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties> </properties>
<build> <build>
@@ -273,6 +274,18 @@
<version>${mockito.version}</version> <version>${mockito.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
+12 -7
View File
@@ -28,7 +28,10 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger; 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.utils.Settings;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
@@ -114,9 +117,11 @@ public class App {
// Allow ApplicationContext itself to be injected into components that need it. // Allow ApplicationContext itself to be injected into components that need it.
context.register(context); context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown. // Persistence: run migrations + build the SessionFactory; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access. SessionManager sessions = context.register(new SessionManager(settings));
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. // Executors: no explicit start today; shut down after music players are disconnected.
ExecutorProvider executors = context.register(new ThreadManager()); ExecutorProvider executors = context.register(new ThreadManager());
@@ -130,12 +135,12 @@ public class App {
CommandRegistry commands = context.register(new CommandManager(context)); CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30). // 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). // 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); LogHandler.bind(guildServices);
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown. // Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors)); music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
@@ -42,7 +42,7 @@ public class BotState {
return; return;
} }
Map<String, GuildService> services = guildServices.getServices(); Map<String, GuildService> services = guildServices.snapshot();
ObjectNode main = Json.object(); ObjectNode main = Json.object();
ArrayNode guildsArray = Json.array(); ArrayNode guildsArray = Json.array();
@@ -56,7 +56,7 @@ public class BotState {
ObjectNode guild = Json.object(); ObjectNode guild = Json.object();
ObjectNode music = Json.object(); ObjectNode music = Json.object();
guild.put("id", service.getEntity().getGuildId()); guild.put("id", service.getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel(); // MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) { // if (currentChannel == null) {
@@ -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.impl.AdminCommand;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; 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.BlacklistService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; 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.List;
import java.util.Objects; import java.util.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand { public class BlacklistCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
@@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString); String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService(); BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService();
switch (sub) { switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
List<OBlacklist> blacklists = service.getBlacklistForGuild(); List<BlacklistEntry> blacklists = service.list();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("List of blacklisted words for this server: \n"); sb.append("List of blacklisted words for this server: \n");
for (OBlacklist bl : blacklists) { for (BlacklistEntry bl : blacklists) {
sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n"); sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n");
} }
if (blacklists.isEmpty()) { if (blacklists.isEmpty()) {
@@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand {
asyncMessage.edit(sb.toString()); asyncMessage.edit(sb.toString());
}); });
case "add" -> { 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" -> { 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(); 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));
};
}
} }
@@ -12,19 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; 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.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType; 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.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; 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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService; 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 { public class ConfigureCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
private final MusicService musicService; private final MusicService musicService;
@@ -76,8 +80,18 @@ public class ConfigureCommand extends AdminCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { 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(); String sub = context.getSubcommand();
GuildService guildService = guildServices.get(context.getGuild().getId()); GuildService guildService = guildServices.get(context.getGuild().getId());
GuildSettingService settingService = guildService.getGuildSettingService();
context.ephemeral(); context.ephemeral();
if ("music-role".equals(sub)) { if ("music-role".equals(sub)) {
@@ -85,13 +99,13 @@ public class ConfigureCommand extends AdminCommand {
assert role != null; assert role != null;
if (role.getPosition() == -1) { 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"); return CommandExecutionResult.success("Everyone can now use music commands");
} }
if (role.getGuild().getId().equals(context.getGuild().getId())) { 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())); 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); GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
if (channel == null) { 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"); return CommandExecutionResult.success("No channel restrictions for music commands");
} }
if (channel instanceof TextChannel) { if (channel instanceof TextChannel) {
if (channel.getGuild().getId().equals(context.getGuild().getId())) { 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())); 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)) { if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt); 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); musicService.get(context.getGuild()).setVolume(volume);
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", 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)) { if ("show-all".equals(sub)) {
Map<String, String> settings = guildService.getGuildSettingService().getSettings(); Map<String, String> settings = settingService.snapshot();
MessageCreateBuilder builder = new MessageCreateBuilder(); MessageCreateBuilder builder = new MessageCreateBuilder();
for (GSettings o : GSettings.values()) { for (GSettings o : GSettings.values()) {
@@ -92,7 +92,7 @@ public class LogHandler {
if (context.getGuild() != null) { if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices; GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) { if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) {
context.getGuild() context.getGuild()
.getTextChannels() .getTextChannels()
.stream() .stream()
@@ -43,7 +43,7 @@ public abstract class MusicCommand extends AbstractCommand {
* @return the guild service * @return the guild service
*/ */
protected final GuildService guildService(Guild guild) { 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(); 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 (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) { if (UserFunction.isAdmin(member)) {
@@ -89,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand {
Role neededRole = guild.getRoleById(roleDb); Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) { if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db // 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; return true;
} }
@@ -106,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand {
return false; 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()) { if (channelDb != null && !channelDb.isEmpty()) {
return channelDb.equals(channel.getId()); return channelDb.equals(channel.getId());
} }
@@ -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.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent; import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry; 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.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException; import me.bobert.netneutrality.exceptions.TaskExecutionException;
@@ -53,8 +54,9 @@ public class TaskManagerImpl implements TaskManager {
* loaded tasks. * 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"); Objects.requireNonNull(context, "ApplicationContext must not be null");
this.tasks = new ConcurrentHashMap<>(); this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new ConcurrentHashMap<>(); this.pendingTasks = new ConcurrentHashMap<>();
@@ -62,7 +64,7 @@ public class TaskManagerImpl implements TaskManager {
this.tasksLock = new ConcurrentHashMap<>(); this.tasksLock = new ConcurrentHashMap<>();
this.context = context; this.context = context;
this.persistableTaskRegistry = new PersistableTaskRegistry(context); this.persistableTaskRegistry = new PersistableTaskRegistry(context);
this.taskService = new TaskService(this, persistableTaskRegistry); this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository);
} }
@Override @Override
@@ -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.
*
* <p>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 <T> the entity type
* @param <ID> the entity identifier type
*/
public abstract class AbstractRepository<T, ID> {
protected final SessionProvider sessions;
private final Class<T> type;
protected AbstractRepository(SessionProvider sessions, Class<T> type) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
this.type = Objects.requireNonNull(type, "type");
}
protected Class<T> type() {
return type;
}
public List<T> findAll() {
return read(session -> {
CriteriaQuery<T> query = session.getCriteriaBuilder().createQuery(type);
query.from(type);
return session.createSelectionQuery(query).getResultList();
});
}
public Optional<T> 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> R read(Function<Session, R> work) {
return sessions.getSessionFactory().fromSession(work);
}
protected <R> R write(Function<Session, R> work) {
return sessions.getSessionFactory().fromTransaction(work);
}
protected void writeVoid(Consumer<Session> work) {
sessions.getSessionFactory().inTransaction(work);
}
}
@@ -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=***]";
}
}
@@ -1,37 +1,38 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult; import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration { public final class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); private static final Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate(SettingsProvider settings) { private DatabaseMigration() {
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);
/**
* 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 { try {
logger.info("Running database migrations.."); logger.info("Running database migrations..");
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dbUrl, dbUser, dbPass) .dataSource(config.url(), config.user(), config.password())
.load(); .load();
MigrateResult result = flyway.migrate(); MigrateResult result = flyway.migrate();
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted); logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
} catch (Exception e) { } catch (Exception e) {
logger.error(e);
throw new IllegalStateException("Database migration failed", e); throw new IllegalStateException("Database migration failed", e);
} }
} }
@@ -1,15 +1,26 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
/**
* Per-guild settings stored in the {@code guild_settings} table.
*
* <p>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 { public enum GSettings {
//TODO: description /** Id of the role required to use music commands; empty means everyone may use them. */
MUSIC_ROLE("MUSIC_ROLE", ""), MUSIC_ROLE("MUSIC_ROLE", ""),
/** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_CHANNEL("MUSIC_CHANNEL", ""), MUSIC_CHANNEL("MUSIC_CHANNEL", ""),
/** Default playback volume (0-100) applied when a music player is created. */
MUSIC_VOLUME("MUSIC_VOLUME", "30"), MUSIC_VOLUME("MUSIC_VOLUME", "30"),
/** Playlist size above which the bot asks for confirmation before queueing every track. */
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20"); MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20");
private final String name; private final String name;
@@ -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.
*
* <p>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<String, GuildService> 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<String, GuildService> 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.
* <p>
* 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.
* <p>
*
* @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<String, GuildService> 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<Session> consumer) {
try (Session session = factory().openSession()) {
consumer.accept(session);
}
}
public static void runWithTransaction(Consumer<Session> 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<Session> consumer) {
try (Session session = factory().openSession()) {
tryLoad(session, persistantEntity, id);
consumer.accept(session);
}
}
}
@@ -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<O> {
Logger logger = LogManager.getLogger(Repository.class);
List<O> 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);
}
@@ -1,4 +1,5 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
@@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory; import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources; import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry; import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder; import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
@@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its * Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
* service registry. * service registry.
* *
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static * <p>{@link #start()} first applies the Flyway migrations and only then builds the session factory,
* persistence helpers can open sessions without a global {@code SessionManager} singleton. * which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}).
*/ */
public class SessionManager implements SessionProvider { public class SessionManager implements SessionProvider {
private static final Logger logger = LogManager.getLogger(SessionManager.class); 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 StandardServiceRegistry registry;
private SessionFactory sessionFactory; private volatile SessionFactory sessionFactory;
public SessionManager(SettingsProvider settings) { public SessionManager(SettingsProvider settings) {
this.settings = settings; this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev")));
JpaUtils.bind(this); }
/**
* @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 @Override
@@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider {
} }
@Override @Override
public void start() { public synchronized void start() {
getSessionFactory(); if (sessionFactory != null) {
DatabaseMigration.migrate(settings); 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<String, Object> 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 @Override
public void stop() { public synchronized void stop() {
SessionFactory factory = sessionFactory;
sessionFactory = null;
if (factory != null) {
factory.close();
}
if (registry != null) { if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry); StandardServiceRegistryBuilder.destroy(registry);
registry = null; registry = null;
sessionFactory = null;
} }
} }
@Override @Override
public SessionFactory getSessionFactory() { public SessionFactory getSessionFactory() {
if (sessionFactory == null) { SessionFactory factory = sessionFactory;
try { if (factory == null) {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure(); throw new IllegalStateException("SessionManager has not been started");
Map<String, Object> 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);
} }
return factory;
stop();
logger.error("Error connecting to database. Shutting down..");
throw new IllegalStateException("Unable to connect to the database", e);
}
}
return sessionFactory;
} }
} }
@@ -15,6 +15,9 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "blacklist") @Table(name = "blacklist")
@@ -76,4 +79,26 @@ public class OBlacklist {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
/**
* 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();
}
} }
@@ -11,9 +11,10 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Optional; import java.util.Objects;
import java.util.Set; import java.util.Set;
@Entity @Entity
@@ -43,14 +44,14 @@ public class OGuild {
private Set<OLogMessage> logMessages = new LinkedHashSet<>(); private Set<OLogMessage> logMessages = new LinkedHashSet<>();
@OneToMany(mappedBy = "guild") @OneToMany(mappedBy = "guild")
private Set<OLogging> OLoggings = new LinkedHashSet<>(); private Set<OLogging> loggings = new LinkedHashSet<>();
public Set<OLogging> getLoggings() { public Set<OLogging> getLoggings() {
return OLoggings; return loggings;
} }
public void setLoggings(Set<OLogging> OLoggings) { public void setLoggings(Set<OLogging> loggings) {
this.OLoggings = OLoggings; this.loggings = loggings;
} }
public Set<OLogMessage> getLogMessages() { public Set<OLogMessage> getLogMessages() {
@@ -110,13 +111,25 @@ public class OGuild {
this.guildId = guildId; this.guildId = guildId;
} }
public OGuildSetting getSettingByName(String name) { /**
Optional<OGuildSetting> setting = getGuildSettings() * Identity is the database id: two instances are equal when they have the same non-null id,
.stream() * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
.filter(setting1 -> setting1.getName().equalsIgnoreCase(name)) */
.findFirst(); @Override
public boolean equals(Object o) {
return setting.orElse(null); 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());
} }
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
} }
@@ -13,11 +13,13 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery; import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "guild_settings") @Table(name = "guild_settings")
@@ -33,8 +35,7 @@ public class OGuildSetting {
@Column(name = "name", nullable = false) @Column(name = "name", nullable = false)
private String name; private String name;
@Lob @Column(name = "value", nullable = false, columnDefinition = "text")
@Column(name = "value", nullable = false)
private String value; private String value;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@@ -72,4 +73,26 @@ public class OGuildSetting {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
/**
* 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();
}
} }
@@ -16,8 +16,10 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.time.Instant; import java.time.Instant;
import java.util.Objects;
@Entity @Entity
@Table(name = "log_message") @Table(name = "log_message")
@@ -113,4 +115,26 @@ public class OLogMessage {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
/**
* 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();
}
} }
@@ -14,6 +14,9 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId; import jakarta.persistence.MapsId;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "logging") @Table(name = "logging")
@@ -52,4 +55,26 @@ public class OLogging {
public void setId(OLoggingId id) { public void setId(OLoggingId id) {
this.id = id; this.id = id;
} }
/**
* 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();
}
} }
@@ -10,9 +10,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})}) @Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})})
@@ -31,8 +33,7 @@ public class OTask {
@Column(name = "task_class", nullable = false) @Column(name = "task_class", nullable = false)
private String taskClass; private String taskClass;
@Lob @Column(name = "payload", columnDefinition = "text")
@Column(name = "payload")
private String payload; private String payload;
public Integer getId() { public Integer getId() {
@@ -67,4 +68,25 @@ public class OTask {
this.payload = payload; this.payload = payload;
} }
/**
* 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();
}
} }
@@ -9,11 +9,12 @@ package me.bobert.netneutrality.db.entities;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.ColumnDefault;
import org.hibernate.Hibernate;
import java.time.Instant; import java.time.Instant;
import java.util.Objects;
@Entity @Entity
@Table(name = "web_users") @Table(name = "web_users")
@@ -22,8 +23,7 @@ public class OWebUser {
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
private String id; private String id;
@Lob @Column(name = "email", nullable = false, columnDefinition = "mediumtext")
@Column(name = "email", nullable = false)
private String email; private String email;
@Column(name = "token", nullable = false) @Column(name = "token", nullable = false)
@@ -87,4 +87,25 @@ public class OWebUser {
this.rememberToken = rememberToken; this.rememberToken = rememberToken;
} }
/**
* 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();
}
} }
@@ -1,104 +1,107 @@
package me.bobert.netneutrality.db.repository; package me.bobert.netneutrality.db.repository;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * 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.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild; 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.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class BlacklistRepository implements Repository<OBlacklist> { public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer> {
@Override /**
public List<OBlacklist> findAll() { * Outcome of {@link #enableWord(String, String, String)}.
List<OBlacklist> blacklists = new ArrayList<>(); */
public enum EnableOutcome {
JpaUtils.runWithSession(session -> { /** A new row was inserted. */
Query<OBlacklist> query = session.createQuery("FROM OBlacklist", OBlacklist.class); CREATED,
/** An existing, disabled row was re-enabled. */
blacklists.addAll(query.list()); ENABLED,
}); /** The word was already enabled. */
UNCHANGED
return blacklists;
} }
@Override public BlacklistRepository(SessionProvider sessions) {
public OBlacklist findById(String id) { super(sessions, OBlacklist.class);
return null;
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OBlacklist> 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 List<OBlacklist> findByGuildId(String guildId) { public List<OBlacklist> findByGuildId(String guildId) {
List<OBlacklist> blacklists = new ArrayList<>(); return read(session -> session
.createSelectionQuery("FROM OBlacklist o WHERE o.guild.guildId = :guildId ORDER BY o.id", OBlacklist.class)
JpaUtils.runWithSession(session -> { .setParameter("guildId", guildId)
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.guild.guildId = :guildId", OBlacklist.class); .getResultList());
query.setParameter("guildId", guildId);
blacklists.addAll(query.list());
});
return blacklists;
} }
public void add(OGuild oGuild, Member member, String word) { public Set<String> findEnabledWords(String guildId) {
JpaUtils.runWithTransaction(session -> { return read(session -> session
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word); .createSelectionQuery("SELECT o.word FROM OBlacklist o WHERE o.guild.guildId = :guildId AND o.enabled = true", String.class)
.setParameter("guildId", guildId)
if (bl == null) { .getResultStream()
bl = new OBlacklist(); .collect(Collectors.toUnmodifiableSet()));
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<OBlacklist> 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;
}
boolean changed = false;
for (OBlacklist blacklist : existing) {
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
blacklist.setEnabled(true);
changed = true;
}
}
return changed ? EnableOutcome.ENABLED : EnableOutcome.UNCHANGED;
}); });
} }
public void delete(OGuild oGuild, String word) { /**
JpaUtils.runWithTransaction(session -> { * Deletes the word (case-insensitive) from the guild's blacklist.
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word); *
if (bl != null) { * @param guildId the guild id
session.remove(bl); * @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<OBlacklist> existing = findWord(session, guildId, word);
existing.forEach(session::remove);
return !existing.isEmpty();
}); });
} }
private OBlacklist getWordFromDb(String guildId, String word) { private static List<OBlacklist> findWord(Session session, String guildId, String word) {
try (Session session = JpaUtils.openSession()) { return session
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class); .createSelectionQuery("FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class)
query.setParameter("guildId", guildId); .setParameter("guildId", guildId)
query.setParameter("word", word); .setParameter("word", word)
OBlacklist blacklist = query.uniqueResult(); .getResultList();
session.close();
return blacklist;
}
} }
} }
@@ -1,8 +1,4 @@
package me.bobert.netneutrality.db.repository;/* package me.bobert.netneutrality.db.repository;
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -10,49 +6,31 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd * 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.OGuild;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList; public class GuildRepository extends AbstractRepository<OGuild, String> {
import java.util.List;
public class GuildRepository implements Repository<OGuild> { public GuildRepository(SessionProvider sessions) {
@Override super(sessions, OGuild.class);
public List<OGuild> findAll() {
List<OGuild> guilds = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OGuild> query = session.createQuery("FROM OGuild", OGuild.class);
guilds.addAll(query.list());
});
return guilds;
} }
@Override /**
public OGuild findById(String id) { * Returns the guild row, inserting it first if it does not exist yet.
try (Session session = JpaUtils.openSession()) { *
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class); * @param guildId the Discord guild id
query.setParameter("guildId", id); * @return the persistent guild
*/
return query.uniqueResult(); public OGuild findOrCreate(String guildId) {
} return write(session -> {
} OGuild guild = session.find(OGuild.class, guildId);
if (guild == null) {
@Override guild = new OGuild();
public void deleteBy(String id) { guild.setGuildId(guildId);
JpaUtils.runWithTransaction(session -> { session.persist(guild);
Query<OGuild> 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);
} }
return guild;
}); });
} }
} }
@@ -1,8 +1,4 @@
package me.bobert.netneutrality.db.repository;/* package me.bobert.netneutrality.db.repository;
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
@@ -10,88 +6,56 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.GSettings; 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.OGuild;
import me.bobert.netneutrality.db.entities.OGuildSetting; 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; import java.util.List;
public class GuildSettingRepository implements Repository<OGuildSetting> { public class GuildSettingRepository extends AbstractRepository<OGuildSetting, Integer> {
@Override
public List<OGuildSetting> findAll() {
List<OGuildSetting> settings = new ArrayList<>();
JpaUtils.runWithSession(session -> { public GuildSettingRepository(SessionProvider sessions) {
Query<OGuildSetting> query = session.createQuery("FROM OGuildSetting", OGuildSetting.class); super(sessions, OGuildSetting.class);
settings.addAll(query.list());
});
return settings;
}
@Override
public OGuildSetting findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OGuildSetting> 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<OGuildSetting> 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 List<OGuildSetting> findByGuild(String guildId) { public List<OGuildSetting> findByGuild(String guildId) {
List<OGuildSetting> settings = new ArrayList<>(); return read(session -> session
.createSelectionQuery("FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class)
JpaUtils.runWithSession(session -> { .setParameter("guildId", guildId)
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class); .getResultList());
query.setParameter("guildId", guildId);
settings.addAll(query.list());
});
return settings;
} }
public void setSettingValue(OGuild oGuild, GSettings setting, String value) { /**
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> { * Stores a non-default value, or deletes the row when the value is {@code null} or equal to the
OGuildSetting oGuildSetting = oGuild.getSettingByName(setting.getName()); * 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<OGuildSetting> existing = session
.createNamedSelectionQuery("settings_findByName", OGuildSetting.class)
.setParameter("id", guildId)
.setParameter("name", setting.getName())
.getResultList();
if (value == null || setting.getDefaultValue().equals(value)) { if (value == null || setting.getDefaultValue().equals(value)) {
if (oGuildSetting != null) { existing.forEach(session::remove);
session.remove(oGuildSetting); return;
} }
} else {
if (oGuildSetting == null) {
oGuildSetting = new OGuildSetting();
oGuildSetting.setGuild(oGuild);
oGuildSetting.setName(setting.getName());
oGuildSetting.setValue(value);
session.persist(oGuildSetting); 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 { } else {
oGuildSetting.setValue(value); existing.forEach(guildSetting -> guildSetting.setValue(value));
session.merge(oGuildSetting);
}
} }
}); });
} }
@@ -1,105 +1,104 @@
package me.bobert.netneutrality.db.repository; package me.bobert.netneutrality.db.repository;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * 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.OGuild;
import me.bobert.netneutrality.db.entities.OLogMessage; 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.Session;
import org.hibernate.query.Query;
import java.util.ArrayList; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
public class MessageRepository implements Repository<OLogMessage> { public class MessageRepository extends AbstractRepository<OLogMessage, Integer> {
@Override
public List<OLogMessage> findAll() {
List<OLogMessage> messages = new ArrayList<>();
JpaUtils.runWithSession(session -> { public MessageRepository(SessionProvider sessions) {
Query<OLogMessage> query = session.createQuery("FROM OLogMessage", OLogMessage.class); super(sessions, OLogMessage.class);
messages.addAll(query.list());
});
return messages;
} }
@Override /**
public OLogMessage findById(String id) { * Stores a logged message.
try (Session session = JpaUtils.openSession()) { *
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); * @param guildId the guild id (the guild row must exist)
query.setParameter("messageId", id); * @param channelId the channel id
* @param userId the author id
return query.uniqueResult(); * @param messageId the Discord message id
} * @param content the raw message content
} * @param sentAt when the message was sent
* @return the persistent log entry
@Override */
public void deleteBy(String id) { public OLogMessage log(String guildId, String channelId, String userId, String messageId, String content, Instant sentAt) {
JpaUtils.runWithTransaction(session -> { return write(session -> {
Query<OLogMessage> 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 -> {
OLogMessage logMessage = new OLogMessage(); OLogMessage logMessage = new OLogMessage();
logMessage.setGuild(oGuild); logMessage.setGuild(session.getReference(OGuild.class, guildId));
logMessage.setMessageId(message.getId()); logMessage.setChannelId(channelId);
logMessage.setMessage(message.getContentRaw().trim()); logMessage.setUserId(userId);
logMessage.setChannelId(channel.getId()); logMessage.setMessageId(messageId);
logMessage.setUserId(author.getId()); logMessage.setMessage(content == null ? null : content.trim());
logMessage.setDateSent(message.getTimeCreated().toInstant()); logMessage.setDateSent(sentAt);
session.persist(logMessage); session.persist(logMessage);
return logMessage;
}); });
} }
public void logEdit(String messageId, String newMessage) { public Optional<OLogMessage> findByMessageId(String messageId) {
JpaUtils.runWithTransaction(session -> { return read(session -> findByMessageId(session, messageId));
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class); }
query.setParameter("messageId", messageId);
OLogMessage message = query.uniqueResult(); public Optional<String> findMessageContent(String messageId) {
if (message != null) { 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); message.setEditedMessage(newMessage);
return true;
session.merge(message); })
} .orElse(false));
});
} }
public List<OLogMessage> getMessagesFromUser(String userId, String guildId, int max) { public boolean deleteByMessageId(String messageId) {
List<OLogMessage> messages = new ArrayList<>(); return write(session -> session
.createMutationQuery("DELETE FROM OLogMessage o WHERE o.messageId = :messageId")
JpaUtils.runWithSession(session -> { .setParameter("messageId", messageId)
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId", OLogMessage.class); .executeUpdate() > 0);
query.setParameter("guildId", guildId);
query.setParameter("userId", userId);
query.setMaxResults(max);
messages.addAll(query.list());
});
return messages;
} }
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<OLogMessage> 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<OLogMessage> findByMessageId(Session session, String messageId) {
return session
.createSelectionQuery("FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class)
.setParameter("messageId", messageId)
.uniqueResultOptional();
} }
} }
@@ -1,55 +1,63 @@
package me.bobert.netneutrality.db.repository; package me.bobert.netneutrality.db.repository;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.Repository; import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OTask; import me.bobert.netneutrality.db.entities.OTask;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList; import java.util.Optional;
import java.util.List;
public class TaskRepository implements Repository<OTask> { public class TaskRepository extends AbstractRepository<OTask, Integer> {
@Override public TaskRepository(SessionProvider sessions) {
public List<OTask> findAll() { super(sessions, OTask.class);
List<OTask> tasks = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OTask> query = session.createQuery("FROM OTask", OTask.class);
tasks.addAll(query.list());
});
return tasks;
} }
@Override public Optional<OTask> findByTaskId(String taskId) {
public OTask findById(String id) { return read(session -> findByTaskId(session, taskId));
try (Session session = JpaUtils.openSession()) {
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
query.setParameter("taskId", id);
return query.uniqueResult();
}
} }
@Override /**
public void deleteBy(String id) { * Inserts the task or updates its payload, in a single transaction.
JpaUtils.runWithTransaction(session -> { *
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class); * @param taskId the logical task id
query.setParameter("taskId", id); * @param typeId the persistable task type id
* @param payload the serialized payload
OTask oTask = query.uniqueResult(); * @return the persistent task
if (oTask != null) { */
session.remove(oTask); 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<OTask> findByTaskId(Session session, String taskId) {
return session
.createSelectionQuery("FROM OTask o WHERE o.taskId = :taskId", OTask.class)
.setParameter("taskId", taskId)
.uniqueResultOptional();
}
} }
@@ -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) {
}
@@ -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
}
@@ -6,93 +6,108 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd * 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.repository.BlacklistRepository;
import me.bobert.netneutrality.db.JpaUtils; import org.apache.logging.log4j.LogManager;
import net.dv8tion.jda.api.entities.Member; import org.apache.logging.log4j.Logger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Locale;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern;
import java.util.stream.Collectors; 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 { public class BlacklistService {
private static final Logger logger = LoggerFactory.getLogger(BlacklistService.class); private static final Logger logger = LogManager.getLogger(BlacklistService.class);
private static final Map<String, Set<String>> blacklistCache = new ConcurrentHashMap<>(); private static final Pattern WORD_SEPARATOR = Pattern.compile("[^\\p{L}\\p{N}]+");
private final BlacklistRepository blacklistRepository; private final BlacklistRepository blacklistRepository;
private final GuildService guildService; private final GuildService guildService;
private volatile Set<String> enabledWords = Set.of();
public BlacklistService(GuildService guildService) { public BlacklistService(GuildService guildService, BlacklistRepository blacklistRepository) {
this.blacklistRepository = new BlacklistRepository(); this.guildService = Objects.requireNonNull(guildService, "guildService");
this.guildService = guildService; this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository");
init();
}
private void init() {
reloadBlacklistCache(); reloadBlacklistCache();
} }
public void reloadBlacklistCache() { public void reloadBlacklistCache() {
OGuild oGuild = guildService.getEntity(); logger.debug("Reloading blacklist cache for guild: {}", guildService.getGuildId());
logger.debug("Reloading blacklist cache for guild: {}", oGuild.getGuildId()); enabledWords = blacklistRepository.findEnabledWords(guildService.getGuildId()).stream()
.map(BlacklistService::normalize)
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> { .collect(Collectors.toUnmodifiableSet());
Set<String> blacklistWords = oGuild.getBlacklists().stream()
.filter(OBlacklist::getEnabled)
.map(OBlacklist::getWord)
.collect(Collectors.toSet());
blacklistCache.put(oGuild.getGuildId(), blacklistWords);
});
} }
public CommandExecutionResult add(Member member, String word) { /**
try { * Adds the word to the blacklist, or re-enables it.
blacklistRepository.add(guildService.getEntity(), member, word); *
* @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(); reloadBlacklistCache();
return CommandExecutionResult.success("Added the word [%s] to the blacklist.", word); return switch (outcome) {
} catch (Exception e) { case CREATED -> BlacklistResult.ADDED;
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage()); case ENABLED -> BlacklistResult.REENABLED;
} case UNCHANGED -> BlacklistResult.ALREADY_EXISTS;
};
} }
public CommandExecutionResult delete(String word) { /**
try { * Removes the word from the blacklist.
blacklistRepository.delete(guildService.getEntity(), word); *
* @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(); reloadBlacklistCache();
return CommandExecutionResult.success("Removed the word [%s] to the blacklist.", word); return deleted ? BlacklistResult.REMOVED : BlacklistResult.NOT_FOUND;
} catch (Exception e) {
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
}
} }
public List<OBlacklist> getBlacklistForGuild() { public List<BlacklistEntry> list() {
try { return blacklistRepository.findByGuildId(guildService.getGuildId()).stream()
return blacklistRepository.findByGuildId(guildService.getEntity().getGuildId()); .map(blacklist -> new BlacklistEntry(blacklist.getWord(), Boolean.TRUE.equals(blacklist.getEnabled())))
} catch (Exception e) { .toList();
return List.of();
} }
/**
* 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<String> 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() { public void removeGuildBlacklist() {
logger.info("Clearing blacklist cache for guild: {}", guildService.getEntity().getGuildId()); logger.info("Clearing blacklist cache for guild: {}", guildService.getGuildId());
blacklistCache.remove(guildService.getEntity().getGuildId()); enabledWords = Set.of();
} }
public static boolean isWordBlacklisted(String guildId, String word) { private static String normalize(String word) {
Set<String> blacklistWords = blacklistCache.get(guildId); return word.toLowerCase(Locale.ROOT);
return blacklistWords != null && blacklistWords.contains(word);
}
public static Set<String> getBlacklist(String guildId) {
return blacklistCache.getOrDefault(guildId, Collections.emptySet());
} }
} }
@@ -7,10 +7,13 @@ package me.bobert.netneutrality.db.service;
*/ */
import me.bobert.netneutrality.db.entities.OGuild; 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.repository.GuildRepository;
import me.bobert.netneutrality.db.JpaUtils; import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.slf4j.Logger; import org.apache.logging.log4j.LogManager;
import org.slf4j.LoggerFactory; import org.apache.logging.log4j.Logger;
import java.util.Objects;
/** /**
* Per-guild domain service (blacklist, settings, entity access). * Per-guild domain service (blacklist, settings, entity access).
@@ -19,16 +22,24 @@ import org.slf4j.LoggerFactory;
* {@link me.bobert.netneutrality.db.service.GuildServiceProvider}. * {@link me.bobert.netneutrality.db.service.GuildServiceProvider}.
*/ */
public class GuildService { public class GuildService {
private static final Logger logger = LoggerFactory.getLogger(GuildService.class); private static final Logger logger = LogManager.getLogger(GuildService.class);
private final GuildRepository guildRepository = new GuildRepository(); private final String guildId;
private final GuildRepository guildRepository;
private final BlacklistService blacklistService; private final BlacklistService blacklistService;
private final GuildSettingService guildSettingService; 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.oGuild = findOrCreateGuild(guildId);
this.blacklistService = new BlacklistService(this); this.blacklistService = new BlacklistService(this, blacklistRepository);
this.guildSettingService = new GuildSettingService(this); this.guildSettingService = new GuildSettingService(this, guildSettingRepository);
}
public String getGuildId() {
return guildId;
} }
public OGuild getEntity() { public OGuild getEntity() {
@@ -43,17 +54,23 @@ public class GuildService {
return guildSettingService; 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) { public OGuild findOrCreateGuild(String guildId) {
OGuild guildEntity = guildRepository.findById(guildId); logger.debug("Loading Guild[{}]", guildId);
if (guildEntity == null) { return guildRepository.findOrCreate(guildId);
logger.debug("Guild[{}] doesn't exist in DB, creating entity..", 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() { public boolean isDebugEnabled() {
@@ -61,14 +78,17 @@ public class GuildService {
} }
public void updateDebugMode(boolean debug) { 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() { public void deleteGuild() {
guildRepository.deleteBy(oGuild.getGuildId()); guildRepository.deleteById(guildId);
blacklistService.removeGuildBlacklist();
} }
public void reloadEntity() { public void reloadEntity() {
this.oGuild = findOrCreateGuild(oGuild.getGuildId()); ensureEntity();
} }
} }
@@ -6,8 +6,6 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map; import java.util.Map;
/** /**
@@ -24,17 +22,16 @@ public interface GuildServiceProvider {
GuildService get(String guildId); 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 * @param guildId the Discord guild id
* @return the guild service
*/ */
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 * @return the services keyed by guild id
*/ */
Map<String, GuildService> getServices(); Map<String, GuildService> snapshot();
} }
@@ -6,9 +6,12 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd * 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.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
@@ -17,22 +20,30 @@ import java.util.concurrent.ConcurrentHashMap;
public class GuildServiceProviderImpl implements GuildServiceProvider { public class GuildServiceProviderImpl implements GuildServiceProvider {
private final Map<String, GuildService> services = new ConcurrentHashMap<>(); private final Map<String, GuildService> 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 @Override
public GuildService get(String guildId) { public GuildService get(String guildId) {
return services.computeIfAbsent(guildId, GuildService::new); return services.computeIfAbsent(guildId,
id -> new GuildService(id, guildRepository, blacklistRepository, guildSettingRepository));
} }
@Override @Override
public GuildService get(Guild guild) { public void evict(String guildId) {
return get(guild.getId()); services.remove(guildId);
} }
@Override @Override
public Map<String, GuildService> getServices() { public Map<String, GuildService> snapshot() {
return services; return Map.copyOf(services);
} }
} }
@@ -9,52 +9,73 @@ package me.bobert.netneutrality.db.service;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.entities.OGuildSetting; import me.bobert.netneutrality.db.entities.OGuildSetting;
import me.bobert.netneutrality.db.repository.GuildSettingRepository; import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.slf4j.Logger; import org.apache.logging.log4j.LogManager;
import org.slf4j.LoggerFactory; import org.apache.logging.log4j.Logger;
import java.util.List; import java.util.HashMap;
import java.util.Map; 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 { public class GuildSettingService {
private static final Logger logger = LoggerFactory.getLogger(GuildSettingService.class); private static final Logger logger = LogManager.getLogger(GuildSettingService.class);
private final GuildSettingRepository guildSettingRepository = new GuildSettingRepository(); private final GuildSettingRepository guildSettingRepository;
private final GuildService guildService; private final GuildService guildService;
private final Map<String, String> settingsCache = new ConcurrentHashMap<>(); private volatile Map<String, String> settingsCache = Map.of();
public GuildSettingService(GuildService guildService) { public GuildSettingService(GuildService guildService, GuildSettingRepository guildSettingRepository) {
this.guildService = guildService; this.guildService = Objects.requireNonNull(guildService, "guildService");
this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository");
reloadSettingsCache(); reloadSettingsCache();
} }
public Map<String, String> getSettings() { /**
* @return an unmodifiable snapshot of the stored (non-default) settings, keyed by setting name
*/
public Map<String, String> snapshot() {
return settingsCache; return settingsCache;
} }
public String getSetting(GSettings setting) { /**
String value = settingsCache.get(setting.getName()); * @param key the setting
* @return the stored value, or empty when the setting uses its default
if (value == null) { */
value = setting.getDefaultValue(); public Optional<String> get(GSettings key) {
return Optional.ofNullable(settingsCache.get(key.getName()));
} }
return 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());
} }
public void setSettingValue(GSettings setting, String value) { /**
guildSettingRepository.setSettingValue(guildService.getEntity(), setting, value); * 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(); reloadSettingsCache();
} }
public void reloadSettingsCache() { public void reloadSettingsCache() {
logger.debug("Reloading settings cache for guild: {}", guildService.getEntity().getGuildId()); logger.debug("Reloading settings cache for guild: {}", guildService.getGuildId());
settingsCache.clear();
Map<String, String> fresh = new HashMap<>();
List<OGuildSetting> settings = guildSettingRepository.findByGuild(guildService.getEntity().getGuildId()); for (OGuildSetting setting : guildSettingRepository.findByGuild(guildService.getGuildId())) {
for (OGuildSetting setting : settings) { fresh.put(setting.getName(), setting.getValue());
settingsCache.put(setting.getName(), setting.getValue()); }
} settingsCache = Map.copyOf(fresh);
} }
} }
@@ -24,10 +24,10 @@ public class TaskService {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final PersistableTaskRegistry registry; private final PersistableTaskRegistry registry;
public TaskService(TaskManager taskManager, PersistableTaskRegistry registry) { public TaskService(TaskManager taskManager, PersistableTaskRegistry registry, TaskRepository taskRepository) {
this.taskManager = taskManager; this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
this.taskRepository = new TaskRepository(); this.registry = Objects.requireNonNull(registry, "registry");
this.registry = registry; this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository");
} }
public void loadTasks() { public void loadTasks() {
@@ -40,13 +40,11 @@ public class TaskService {
String typeId = oTask.getTaskClass(); String typeId = oTask.getTaskClass();
logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId); logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId);
String payload = oTask.getPayload();
try { try {
ObjectNode json = Json.readObject(payload); ObjectNode json = Json.readObject(oTask.getPayload());
PersistableTask task = registry.create(typeId, json); PersistableTask task = registry.create(typeId, json);
validateTask(task, oTask); validateTask(task, taskId, json);
taskManager.insertPersistableTask(task); taskManager.insertPersistableTask(task);
} catch (Exception e) { } catch (Exception e) {
logger.error("Error loading task with ID {}: {}", taskId, e.getMessage()); 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 { private static void validateTask(PersistableTask task, String taskId, ObjectNode json) throws IllegalArgumentException {
String taskId = oTask.getTaskId();
String payload = oTask.getPayload();
if (!Objects.equals(taskId, task.getId())) { if (!Objects.equals(taskId, task.getId())) {
throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId()); throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId());
} }
ObjectNode json = Json.readObject(payload);
String payloadId = json.path("id").asText(null); String payloadId = json.path("id").asText(null);
if (!Objects.equals(payloadId, task.getId())) { if (!Objects.equals(payloadId, task.getId())) {
throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId()); throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId());
@@ -81,24 +75,12 @@ public class TaskService {
return; return;
} }
TaskRepository taskRepository = new TaskRepository(); logger.info("Persisting task: {}", task.getId());
OTask oTask = taskRepository.findById(task.getId()); taskRepository.upsert(task.getId(), typeId, task.toPayload());
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);
} }
public void unpersistTask(PersistableTask task) { public void unpersistTask(PersistableTask task) {
logger.debug("Unpersisting task: {}", task.getId()); logger.debug("Unpersisting task: {}", task.getId());
TaskRepository taskRepository = new TaskRepository(); taskRepository.deleteByTaskId(task.getId());
taskRepository.deleteBy(task.getId());
} }
} }
@@ -95,13 +95,29 @@ public class EventsHandler implements EventListener {
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new); addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new); addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
addGenericListener(GuildJoinEvent.class, event -> { addGenericListener(GuildJoinEvent.class, event -> {
GuildService service = guildServices.get(event.getGuild()); GuildService service = guildServices.get(event.getGuild().getId());
if (service.isDebugEnabled()) { if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel"); 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);
}
} }
/** /**
@@ -52,14 +52,14 @@ public final class ActionRoutes {
routes.get("/actions/settings/current/{guildId}", ctx -> { routes.get("/actions/settings/current/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild).getGuildSettingService().getSettings(); Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
ctx.json(Map.of("status", "ok", "settings", settings)); ctx.json(Map.of("status", "ok", "settings", settings));
}); });
routes.post("/actions/settings/reload/{guildId}", ctx -> { routes.post("/actions/settings/reload/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild).getGuildSettingService().reloadSettingsCache(); guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", "true")); ctx.json(Map.of("status", "true"));
}); });
@@ -259,7 +259,7 @@ public class MusicManager implements MusicManagerCallback {
private static int parseBaseVolume(GuildService guildService) { private static int parseBaseVolume(GuildService guildService) {
try { try {
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME)); return Integer.parseInt(guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOLUME));
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e); logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e);
return DEFAULT_VOLUME; return DEFAULT_VOLUME;
@@ -105,7 +105,7 @@ public class MusicServiceImpl implements MusicService {
public MusicManager get(Guild guild) { public MusicManager get(Guild guild) {
return managers.computeIfAbsent( return managers.computeIfAbsent(
guild.getId(), guild.getId(),
id -> new MusicManager(guild, client, guildServices.get(guild), taskManager) id -> new MusicManager(guild, client, guildServices.get(id), taskManager)
); );
} }
@@ -63,7 +63,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
int searchResultsLimit = 5; // Max number of buttons per row int searchResultsLimit = 5; // Max number of buttons per row
int maxPlaylistSizeBeforeAsking = Integer.parseInt(musicManager.getGuildService() int maxPlaylistSizeBeforeAsking = Integer.parseInt(musicManager.getGuildService()
.getGuildSettingService() .getGuildSettingService()
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE)); .getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE));
// Search results // Search results
if (searchResult) { if (searchResult) {
+3
View File
@@ -7,6 +7,8 @@
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property> <property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property> <property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.provider_class">org.hibernate.hikaricp.internal.HikariCPConnectionProvider</property> <property name="hibernate.connection.provider_class">org.hibernate.hikaricp.internal.HikariCPConnectionProvider</property>
<!-- The schema is owned by Flyway; Hibernate only checks that the entities match it. -->
<property name="hibernate.hbm2ddl.auto">validate</property>
<mapping class="me.bobert.netneutrality.db.entities.OBlacklist"/> <mapping class="me.bobert.netneutrality.db.entities.OBlacklist"/>
<mapping class="me.bobert.netneutrality.db.entities.OGuild"/> <mapping class="me.bobert.netneutrality.db.entities.OGuild"/>
@@ -15,6 +17,7 @@
<mapping class="me.bobert.netneutrality.db.entities.OLoggingId"/> <mapping class="me.bobert.netneutrality.db.entities.OLoggingId"/>
<mapping class="me.bobert.netneutrality.db.entities.OLogMessage"/> <mapping class="me.bobert.netneutrality.db.entities.OLogMessage"/>
<mapping class="me.bobert.netneutrality.db.entities.OTask"/> <mapping class="me.bobert.netneutrality.db.entities.OTask"/>
<mapping class="me.bobert.netneutrality.db.entities.OWebUser"/>
</session-factory> </session-factory>
</hibernate-configuration> </hibernate-configuration>
@@ -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"));
}
}
@@ -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.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent; 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.db.service.TaskService;
import me.bobert.netneutrality.scheduled.cleanup.TaskCleanup; import me.bobert.netneutrality.scheduled.cleanup.TaskCleanup;
import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.components.buttons.Button;
@@ -61,7 +62,7 @@ class TaskManagerImplTest {
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() throws Exception {
taskManager = new TaskManagerImpl(applicationContext); taskManager = new TaskManagerImpl(applicationContext, mock(TaskRepository.class));
Field taskServiceField = TaskManagerImpl.class.getDeclaredField("taskService"); Field taskServiceField = TaskManagerImpl.class.getDeclaredField("taskService");
taskServiceField.setAccessible(true); taskServiceField.setAccessible(true);
@@ -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<OGuild, String> {
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;
});
}
}
}
@@ -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<String> FORBIDDEN = List.of(
"import net.dv8tion.jda.",
"import me.bobert.netneutrality.core.command."
);
@Test
void servicesAndRepositoriesDoNotImportJdaOrCommandTypes() throws IOException {
List<String> violations;
try (Stream<Path> 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<Path> files = Files.walk(DB_SOURCES.resolve("service"))) {
assertFalse(files.filter(path -> path.toString().endsWith(".java")).toList().isEmpty());
}
}
private static Stream<String> 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);
}
}
}
@@ -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.
*
* <p>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<String> 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();
});
}
}
@@ -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"));
}
}
@@ -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);
}
}
@@ -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<OGuildSetting> 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);
}
}
@@ -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"));
}
}
@@ -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<OGuildSetting> all = settings.findByGuild(GUILD);
assertEquals(1, all.size());
return all.getFirst();
}
}
@@ -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<String> ids = messages.findByUser("user-1", GUILD, 2).stream().map(OLogMessage::getMessageId).toList();
assertEquals(List.of("new", "mid"), ids);
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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<Void> 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;
}
}
@@ -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");
}
}
@@ -58,13 +58,13 @@ class WebApiTest {
when(jda.getGatewayPing()).thenReturn(12L); when(jda.getGatewayPing()).thenReturn(12L);
settingService = mock(GuildSettingService.class); settingService = mock(GuildSettingService.class);
when(settingService.getSettings()).thenReturn(Map.of("prefix", "ü!")); when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!"));
GuildService guildService = mock(GuildService.class); GuildService guildService = mock(GuildService.class);
when(guildService.getGuildSettingService()).thenReturn(settingService); when(guildService.getGuildSettingService()).thenReturn(settingService);
GuildServiceProvider provider = mock(GuildServiceProvider.class); GuildServiceProvider provider = mock(GuildServiceProvider.class);
when(provider.get(guild)).thenReturn(guildService); when(provider.get(GUILD_ID)).thenReturn(guildService);
SettingsProvider settings = mock(SettingsProvider.class); SettingsProvider settings = mock(SettingsProvider.class);
when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port)); when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));