Skip to content

Commit 5b03d7f

Browse files
committed
feature: add jOOQ persistence layer with remote test support
## Problem Solved - No database persistence for licenses and device activations - Integration tests require Docker which isn't available in all environments ## Solution Implemented - Add jOOQ, PostgreSQL, HikariCP, Flyway, Testcontainers dependencies - Create V1 schema migration with license and device_activation tables - Implement Database connection manager with HikariCP pooling - Add LicenseRepository and DeviceActivationRepository - Integration tests using Testcontainers PostgreSQL - Remote test runner that triggers GitHub Actions and waits for results ## Usage - `mvn test` - runs unit tests only (skips Docker-dependent tests) - `mvn test -P integration-tests` - runs all tests (requires Docker) - `mvn verify -P remote-test` - triggers tests on GitHub Actions Task ID: v2.0-license-validation-server-persistence
1 parent e9c0051 commit 5b03d7f

12 files changed

Lines changed: 1352 additions & 3 deletions

File tree

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# State
22

3-
- **Status:** pending
4-
- **Progress:** 0%
3+
- **Status:** completed
4+
- **Progress:** 100%
5+
- **Resolution:** implemented
56
- **Dependencies:** [license-validation-server-jetty-setup]
6-
- **Last Updated:** 2026-01-23
7+
- **Completed:** 2026-01-24 14:30
8+
- **Tokens Used:** ~45,000
9+
- **Last Updated:** 2026-01-24
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Integration Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- '**'
7+
paths:
8+
- 'server/**'
9+
- '.github/workflows/integration-tests.yml'
10+
workflow_dispatch:
11+
inputs:
12+
ref:
13+
description: 'Git ref to test'
14+
required: false
15+
default: ''
16+
17+
jobs:
18+
test:
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
with:
24+
ref: ${{ github.event.inputs.ref || github.ref }}
25+
26+
- name: Set up JDK 21
27+
uses: actions/setup-java@v4
28+
with:
29+
java-version: '21'
30+
distribution: 'temurin'
31+
cache: maven
32+
33+
- name: Run integration tests
34+
run: mvn test -f server/pom.xml -P integration-tests
35+
36+
- name: Upload test results
37+
uses: actions/upload-artifact@v4
38+
if: always()
39+
with:
40+
name: test-results
41+
path: server/target/surefire-reports/

server/pom.xml

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
<jetty.version>12.1.5</jetty.version>
2121
<slf4j.version>2.0.17</slf4j.version>
2222
<jackson.version>3.0.3</jackson.version>
23+
<jooq.version>3.19.15</jooq.version>
24+
<postgresql.version>42.7.4</postgresql.version>
25+
<hikaricp.version>5.1.0</hikaricp.version>
26+
<flyway.version>10.21.0</flyway.version>
27+
<testcontainers.version>1.19.8</testcontainers.version>
2328
</properties>
2429

2530
<dependencies>
@@ -47,6 +52,48 @@
4752
<artifactId>slf4j-simple</artifactId>
4853
<version>${slf4j.version}</version>
4954
</dependency>
55+
<!-- jOOQ for type-safe SQL -->
56+
<dependency>
57+
<groupId>org.jooq</groupId>
58+
<artifactId>jooq</artifactId>
59+
<version>${jooq.version}</version>
60+
</dependency>
61+
<!-- PostgreSQL JDBC driver -->
62+
<dependency>
63+
<groupId>org.postgresql</groupId>
64+
<artifactId>postgresql</artifactId>
65+
<version>${postgresql.version}</version>
66+
</dependency>
67+
<!-- HikariCP connection pool -->
68+
<dependency>
69+
<groupId>com.zaxxer</groupId>
70+
<artifactId>HikariCP</artifactId>
71+
<version>${hikaricp.version}</version>
72+
</dependency>
73+
<!-- Flyway database migrations -->
74+
<dependency>
75+
<groupId>org.flywaydb</groupId>
76+
<artifactId>flyway-core</artifactId>
77+
<version>${flyway.version}</version>
78+
</dependency>
79+
<dependency>
80+
<groupId>org.flywaydb</groupId>
81+
<artifactId>flyway-database-postgresql</artifactId>
82+
<version>${flyway.version}</version>
83+
</dependency>
84+
<!-- Testcontainers for integration testing -->
85+
<dependency>
86+
<groupId>org.testcontainers</groupId>
87+
<artifactId>testcontainers</artifactId>
88+
<version>${testcontainers.version}</version>
89+
<scope>test</scope>
90+
</dependency>
91+
<dependency>
92+
<groupId>org.testcontainers</groupId>
93+
<artifactId>postgresql</artifactId>
94+
<version>${testcontainers.version}</version>
95+
<scope>test</scope>
96+
</dependency>
5097
</dependencies>
5198

5299
<build>
@@ -80,6 +127,127 @@
80127
</archive>
81128
</configuration>
82129
</plugin>
130+
<plugin>
131+
<groupId>org.jooq</groupId>
132+
<artifactId>jooq-codegen-maven</artifactId>
133+
<version>${jooq.version}</version>
134+
<executions>
135+
<execution>
136+
<id>generate-jooq</id>
137+
<phase>generate-sources</phase>
138+
<goals>
139+
<goal>generate</goal>
140+
</goals>
141+
</execution>
142+
</executions>
143+
<configuration>
144+
<generator>
145+
<database>
146+
<name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
147+
<properties>
148+
<property>
149+
<key>scripts</key>
150+
<value>src/main/resources/db/migration/*.sql</value>
151+
</property>
152+
<property>
153+
<key>sort</key>
154+
<value>flyway</value>
155+
</property>
156+
<property>
157+
<key>defaultNameCase</key>
158+
<value>lower</value>
159+
</property>
160+
</properties>
161+
</database>
162+
<target>
163+
<packageName>io.github.cowwoc.claudecodecat.persistence.generated</packageName>
164+
<directory>target/generated-sources/jooq</directory>
165+
</target>
166+
</generator>
167+
</configuration>
168+
<dependencies>
169+
<dependency>
170+
<groupId>org.jooq</groupId>
171+
<artifactId>jooq-meta-extensions</artifactId>
172+
<version>${jooq.version}</version>
173+
</dependency>
174+
</dependencies>
175+
</plugin>
176+
<plugin>
177+
<groupId>org.codehaus.mojo</groupId>
178+
<artifactId>exec-maven-plugin</artifactId>
179+
<version>3.1.1</version>
180+
</plugin>
83181
</plugins>
84182
</build>
183+
184+
<profiles>
185+
<!-- Default: Skip integration tests (require Docker) -->
186+
<profile>
187+
<id>skip-integration-tests</id>
188+
<activation>
189+
<activeByDefault>true</activeByDefault>
190+
</activation>
191+
<build>
192+
<plugins>
193+
<plugin>
194+
<groupId>org.apache.maven.plugins</groupId>
195+
<artifactId>maven-surefire-plugin</artifactId>
196+
<configuration>
197+
<excludes>
198+
<exclude>**/persistence/*Test.java</exclude>
199+
</excludes>
200+
</configuration>
201+
</plugin>
202+
</plugins>
203+
</build>
204+
</profile>
205+
206+
<!-- Run all tests including integration tests (requires Docker) -->
207+
<profile>
208+
<id>integration-tests</id>
209+
<build>
210+
<plugins>
211+
<plugin>
212+
<groupId>org.apache.maven.plugins</groupId>
213+
<artifactId>maven-surefire-plugin</artifactId>
214+
<configuration>
215+
<excludes>
216+
<!-- No exclusions - run everything -->
217+
</excludes>
218+
</configuration>
219+
</plugin>
220+
</plugins>
221+
</build>
222+
</profile>
223+
224+
<!-- Trigger remote tests on GitHub Actions -->
225+
<profile>
226+
<id>remote-test</id>
227+
<properties>
228+
<skipTests>true</skipTests>
229+
</properties>
230+
<build>
231+
<plugins>
232+
<plugin>
233+
<groupId>org.codehaus.mojo</groupId>
234+
<artifactId>exec-maven-plugin</artifactId>
235+
<executions>
236+
<execution>
237+
<id>run-remote-tests</id>
238+
<phase>verify</phase>
239+
<goals>
240+
<goal>java</goal>
241+
</goals>
242+
<configuration>
243+
<mainClass>io.github.cowwoc.claudecodecat.build.RemoteTestRunner</mainClass>
244+
<classpathScope>test</classpathScope>
245+
</configuration>
246+
</execution>
247+
</executions>
248+
</plugin>
249+
</plugins>
250+
</build>
251+
</profile>
252+
</profiles>
85253
</project>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package io.github.cowwoc.claudecodecat.persistence;
2+
3+
import com.zaxxer.hikari.HikariConfig;
4+
import com.zaxxer.hikari.HikariDataSource;
5+
import org.flywaydb.core.Flyway;
6+
import org.jooq.DSLContext;
7+
import org.jooq.SQLDialect;
8+
import org.jooq.impl.DSL;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
12+
import javax.sql.DataSource;
13+
import java.util.Objects;
14+
15+
/**
16+
* Database connection manager with HikariCP connection pooling and Flyway migrations.
17+
*/
18+
public final class Database implements AutoCloseable
19+
{
20+
private static final Logger LOGGER = LoggerFactory.getLogger(Database.class);
21+
22+
private final HikariDataSource dataSource;
23+
private final DSLContext dsl;
24+
25+
/**
26+
* Creates a new database connection manager.
27+
*
28+
* @param jdbcUrl the JDBC URL for the PostgreSQL database
29+
* @param username the database username
30+
* @param password the database password
31+
* @throws NullPointerException if any argument is null
32+
*/
33+
public Database(String jdbcUrl, String username, String password)
34+
{
35+
Objects.requireNonNull(jdbcUrl, "jdbcUrl may not be null");
36+
Objects.requireNonNull(username, "username may not be null");
37+
Objects.requireNonNull(password, "password may not be null");
38+
39+
HikariConfig config = new HikariConfig();
40+
config.setJdbcUrl(jdbcUrl);
41+
config.setUsername(username);
42+
config.setPassword(password);
43+
config.setMaximumPoolSize(10);
44+
config.setMinimumIdle(2);
45+
config.setConnectionTimeout(30_000);
46+
config.setIdleTimeout(600_000);
47+
config.setMaxLifetime(1_800_000);
48+
config.setPoolName("cat-license-pool");
49+
50+
this.dataSource = new HikariDataSource(config);
51+
this.dsl = DSL.using(dataSource, SQLDialect.POSTGRES);
52+
53+
LOGGER.info("Database connection pool initialized: {}", jdbcUrl);
54+
}
55+
56+
/**
57+
* Runs Flyway migrations against this database.
58+
*/
59+
public void migrate()
60+
{
61+
LOGGER.info("Running database migrations...");
62+
Flyway flyway = Flyway.configure()
63+
.dataSource(dataSource)
64+
.locations("classpath:db/migration")
65+
.load();
66+
int migrationsApplied = flyway.migrate().migrationsExecuted;
67+
LOGGER.info("Applied {} migration(s)", migrationsApplied);
68+
}
69+
70+
/**
71+
* Returns the jOOQ DSLContext for executing queries.
72+
*
73+
* @return the DSLContext
74+
*/
75+
public DSLContext dsl()
76+
{
77+
return dsl;
78+
}
79+
80+
/**
81+
* Returns the underlying DataSource for direct JDBC access if needed.
82+
*
83+
* @return the DataSource
84+
*/
85+
public DataSource dataSource()
86+
{
87+
return dataSource;
88+
}
89+
90+
/**
91+
* Shuts down the connection pool and releases all resources.
92+
*/
93+
@Override
94+
public void close()
95+
{
96+
LOGGER.info("Shutting down database connection pool...");
97+
dataSource.close();
98+
LOGGER.info("Database connection pool closed");
99+
}
100+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.github.cowwoc.claudecodecat.persistence;
2+
3+
import java.time.OffsetDateTime;
4+
import java.util.Objects;
5+
import java.util.UUID;
6+
7+
/**
8+
* A device activation record linking a device to a license.
9+
*
10+
* @param id the unique activation identifier
11+
* @param licenseId the license this activation belongs to
12+
* @param deviceFingerprint the unique device identifier
13+
* @param activatedAt when the device was activated
14+
*/
15+
public record DeviceActivation(
16+
UUID id,
17+
UUID licenseId,
18+
String deviceFingerprint,
19+
OffsetDateTime activatedAt)
20+
{
21+
/**
22+
* Creates a new DeviceActivation.
23+
*
24+
* @param id the unique activation identifier
25+
* @param licenseId the license this activation belongs to
26+
* @param deviceFingerprint the unique device identifier
27+
* @param activatedAt when the device was activated
28+
* @throws NullPointerException if any argument is null
29+
*/
30+
public DeviceActivation
31+
{
32+
Objects.requireNonNull(id, "id may not be null");
33+
Objects.requireNonNull(licenseId, "licenseId may not be null");
34+
Objects.requireNonNull(deviceFingerprint, "deviceFingerprint may not be null");
35+
Objects.requireNonNull(activatedAt, "activatedAt may not be null");
36+
}
37+
38+
/**
39+
* Creates a new device activation with a generated ID and current timestamp.
40+
*
41+
* @param licenseId the license to activate
42+
* @param deviceFingerprint the unique device identifier
43+
* @return a new DeviceActivation instance
44+
*/
45+
public static DeviceActivation create(UUID licenseId, String deviceFingerprint)
46+
{
47+
return new DeviceActivation(UUID.randomUUID(), licenseId, deviceFingerprint, OffsetDateTime.now());
48+
}
49+
}

0 commit comments

Comments
 (0)