diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c55ac0a..c7101d54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,14 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: no-docker + mvn-args: "-Pno-docker" + - name: docker + mvn-args: "" permissions: contents: read issues: write @@ -25,8 +33,8 @@ jobs: java-version: '21' distribution: 'temurin' cache: 'maven' - - name: Build with Maven - run: ./mvnw -q verify |& tee build.log + - name: Build with Maven (${{ matrix.name }}) + run: ./mvnw -q ${{ matrix.mvn-args }} verify |& tee build.log - name: Show build log if: failure() run: | @@ -58,7 +66,7 @@ jobs: uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - - name: Create issue on failure + - name: Create issue on failure (${{ matrix.name }}) if: failure() && github.ref == 'refs/heads/develop' uses: actions/github-script@v7 with: @@ -72,7 +80,7 @@ jobs: await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `CI build failed for ${context.sha.slice(0,7)}`, - body: `Build failed for commit ${context.sha} in workflow run ${context.runId}.\\n\\nBuild error:\\n\\n\u0060\u0060\u0060\\n${errors}\\n\u0060\u0060\u0060\\n\\nLast lines of build log:\\n\\n\u0060\u0060\u0060\\n${log}\\n\u0060\u0060\u0060`, + title: `CI (${{ matrix.name }}) failed for ${context.sha.slice(0,7)}`, + body: `Build failed for commit ${context.sha} in workflow run ${context.runId} (matrix: ${{ matrix.name }}).\\n\\nBuild error:\\n\\n\u0060\u0060\u0060\\n${errors}\\n\u0060\u0060\u0060\\n\\nLast lines of build log:\\n\\n\u0060\u0060\u0060\\n${log}\\n\u0060\u0060\u0060`, labels: ['ci'] }); diff --git a/.project-management/ISSUES_OPERATIONS.md b/.project-management/ISSUES_OPERATIONS.md new file mode 100644 index 00000000..37ab7ebe --- /dev/null +++ b/.project-management/ISSUES_OPERATIONS.md @@ -0,0 +1,30 @@ +# Follow-up Issues: Operations Documentation + +Create the following GitHub issues to track operations docs and examples. + +1) Ops: Micrometer integration examples +- Show counters via `MeterRegistry` (simple counters, timers around send) +- Listener wiring (`onSendFailures`) increments counters +- Sample Prometheus scrape via micrometer-registry-prometheus + +2) Ops: Prometheus exporter example +- Minimal HTTP endpoint exposing counters +- Translate `DefaultNoteService.FailureInfo` into metrics labels (relay) +- Include guidance on cardinality + +3) Ops: Logging patterns and correlation IDs +- MDC usage to correlate sends with subscriptions +- Recommended logger categories & sample filters +- JSON logging example (Logback) + +4) Ops: Configuration deep-dive +- Advanced timeouts and backoff strategies (pros/cons) +- When to adjust `await-timeout-ms` / `poll-interval-ms` +- Retry tuning beyond defaults and trade-offs + +5) Ops: Diagnostics cookbook +- Common failure scenarios and how to interpret FailureInfo +- Mapping failures to remediation steps +- Cross-relay differences and best practices + +Note: Opening issues requires repository permissions; add the above as individual issues with `docs` and `operations` labels. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcd80ae5..4571e6f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,6 +144,19 @@ See [docs/explanation/architecture.md](docs/explanation/architecture.md) for det - **Test all edge cases:** null values, empty strings, invalid inputs - **Use descriptive test names** or `@DisplayName` +### Client/Handler tests + +- See `nostr-java-api/src/test/java/nostr/api/client/README.md` for structure and naming. +- Naming conventions: + - `NostrSpringWebSocketClient*` for high‑level client behavior + - `WebSocketHandler*` for internal handler semantics (send/close/request) + - `NostrRequestDispatcher*` and `NostrSubscriptionManager*` for dispatcher/manager lifecycles +- Use `nostr.api.TestHandlerFactory` to construct `WebSocketClientHandler` from tests outside `nostr.api`. + +### Client module tests + +- See `nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md` for an overview of the Spring WebSocket client test suite (retry/subscribe/timeout behavior). + ### Test Example ```java @@ -186,4 +199,4 @@ void testValidateKindRejectsNegative() { - Summaries in pull requests must cite file paths and include testing output. - Open pull requests using the template at `.github/pull_request_template.md` and complete every section. -By following these conventions, contributors help keep the codebase maintainable and aligned with the Nostr specifications. \ No newline at end of file +By following these conventions, contributors help keep the codebase maintainable and aligned with the Nostr specifications. diff --git a/README.md b/README.md index 89449077..72ab39e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # nostr-java [![CI](https://github.com/tcheeric/nostr-java/actions/workflows/ci.yml/badge.svg)](https://github.com/tcheeric/nostr-java/actions/workflows/ci.yml) +[![CI Matrix: docker + no-docker](https://img.shields.io/badge/CI%20Matrix-docker%20%2B%20no--docker-blue)](https://github.com/tcheeric/nostr-java/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/tcheeric/nostr-java/branch/main/graph/badge.svg)](https://codecov.io/gh/tcheeric/nostr-java) [![GitHub release](https://img.shields.io/github/v/release/tcheeric/nostr-java)](https://github.com/tcheeric/nostr-java/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) @@ -13,9 +14,64 @@ See [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) for installation and usage instructions. +## Running Tests + +- Full test suite (requires Docker for Testcontainers ITs): + + `mvn -q verify` + +- Without Docker (skips Testcontainers-based integration tests via profile): + + `mvn -q -Pno-docker verify` + +The `no-docker` profile excludes tests under `**/nostr/api/integration/**` and sets `noDocker=true` for conditional test disabling. + +### Troubleshooting failed relay sends + +When broadcasting to multiple relays, failures on individual relays are tolerated and sending continues to other relays. To inspect which relays failed during the last send on the current thread: + +```java +// Using the default client setup +NostrSpringWebSocketClient client = new NostrSpringWebSocketClient(sender); +client.setRelays(Map.of( + "relayA", "wss://relayA.example.com", + "relayB", "wss://relayB.example.com" +)); + +List responses = client.sendEvent(event); +// Inspect failures (if using DefaultNoteService) +Map failures = client.getLastSendFailures(); +failures.forEach((relay, error) -> + System.out.println("Relay " + relay + " failed: " + error.getMessage()) +); +``` + +This returns an empty map if a custom `NoteService` is used that does not expose diagnostics. + +To receive failure notifications immediately after each send attempt when using the default client: + +```java +client.onSendFailures(map -> { + map.forEach((relay, t) -> System.err.println( + "Send failed on relay " + relay + ": " + t.getClass().getSimpleName() + ": " + t.getMessage() + )); +}); +``` + +For more detail (timestamp, class, message), use: + +```java +Map info = client.getLastSendFailureDetails(); +info.forEach((relay, d) -> System.out.printf( + "[%d] %s failed: %s - %s%n", + d.timestampEpochMillis, relay, d.exceptionClass, d.message +)); +``` + ## Documentation - Docs index: [docs/README.md](docs/README.md) — quick entry point to all guides and references. +- Operations: [docs/operations/README.md](docs/operations/README.md) — logging, metrics, configuration, diagnostics. - Getting started: [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — install via Maven/Gradle and build from source. - API how‑to: [docs/howto/use-nostr-java-api.md](docs/howto/use-nostr-java-api.md) — create, sign, and publish basic events. - Streaming subscriptions: [docs/howto/streaming-subscriptions.md](docs/howto/streaming-subscriptions.md) — open and manage long‑lived, non‑blocking subscriptions. diff --git a/docs/README.md b/docs/README.md index be6ed0a5..d4f37167 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,11 @@ Quick links to the most relevant guides and references. - [howto/streaming-subscriptions.md](howto/streaming-subscriptions.md) — Long-lived subscriptions - [howto/custom-events.md](howto/custom-events.md) — Creating custom event types +## Operations + +- [operations/README.md](operations/README.md) — Ops index (logging, metrics, config) +- [howto/diagnostics.md](howto/diagnostics.md) — Inspecting relay failures and troubleshooting + ## Reference - [reference/nostr-java-api.md](reference/nostr-java-api.md) — API classes, methods, and examples @@ -26,3 +31,8 @@ Quick links to the most relevant guides and references. ## Project - [CODEBASE_OVERVIEW.md](CODEBASE_OVERVIEW.md) — Codebase layout, testing, contributing + +## Tests Overview + +- API Client/Handler tests: `nostr-java-api/src/test/java/nostr/api/client/README.md` — logging, relays, handler send/close/request, dispatcher & subscription manager +- Client module (Spring WebSocket): `nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md` — send/subscribe retries and timeout behavior diff --git a/docs/howto/diagnostics.md b/docs/howto/diagnostics.md new file mode 100644 index 00000000..661fa784 --- /dev/null +++ b/docs/howto/diagnostics.md @@ -0,0 +1,86 @@ +# Diagnostics: Relay Failures and Troubleshooting + +This how‑to shows how to inspect, capture, and react to relay send failures when broadcasting events via the API client. + +## Overview + +- `DefaultNoteService` attempts to send an event to all configured relays. +- Failures on individual relays are tolerated; other relays are still attempted. +- After the send completes, you can inspect failures and structured details. +- You can also register a listener to receive failures in real time. + +## Inspect last failures + +```java +NostrSpringWebSocketClient client = new NostrSpringWebSocketClient(sender); +client.setRelays(Map.of( + "relayA", "wss://relayA.example.com", + "relayB", "wss://relayB.example.com" +)); + +List responses = client.sendEvent(event); + +// Map: relay name to exception +Map failures = client.getLastSendFailures(); +failures.forEach((relay, error) -> System.err.printf( + "Relay %s failed: %s%n", relay, error.getMessage() +)); + +// Structured details (timestamp, relay URI, cause chain summary) +Map details = client.getLastSendFailureDetails(); +details.forEach((relay, info) -> System.err.printf( + "[%d] %s (%s) failed: %s | root: %s - %s%n", + info.timestampEpochMillis, + info.relayName, + info.relayUri, + info.message, + info.rootCauseClass, + info.rootCauseMessage +)); +``` + +Note: If you use a custom `NoteService`, these accessors return empty maps unless the implementation exposes diagnostics. + +## Receive failures with a listener + +Register a callback to receive the failures map immediately after each send attempt: + +```java +client.onSendFailures(failureMap -> { + failureMap.forEach((relay, t) -> System.err.printf( + "Failure on %s: %s: %s%n", + relay, t.getClass().getSimpleName(), t.getMessage() + )); +}); +``` + +## Tips + +- Partial success is common on public relays; prefer aggregating successful responses. +- Use `getLastSendFailureDetails()` when you need to correlate failures with relay URIs or log timestamps. +- Combine diagnostics with your retry/backoff strategy at the application level if needed. + +## MDC snippet (correlate logs per send) + +Use SLF4J MDC to attach a correlation id for a send. Remember to clear the MDC in `finally`. + +```java +import org.slf4j.MDC; +import java.util.UUID; + +String correlationId = UUID.randomUUID().toString(); +MDC.put("corrId", correlationId); +try { + var responses = client.sendEvent(event); + // Your logging here; include %X{corrId} in your log pattern + log.info("Sent event id={} corrId={} responses={}", event.getId(), correlationId, responses.size()); +} finally { + MDC.remove("corrId"); +} +``` + +Logback pattern example: + +```properties +logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%X{corrId}] %logger{36} - %msg%n +``` diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 00000000..9fedbed2 --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,16 @@ +# Operations + +Operational guidance and runbook-style topics for nostr-java. + +## Topics + +- Diagnostics and Failures + - See how-to: [../howto/diagnostics.md](../howto/diagnostics.md) +- Logging + - Recommended logger setup, categories, and verbosity — see [logging.md](logging.md) +- Metrics + - Exporting client metrics and subscription activity — see [metrics.md](metrics.md) +- Configuration + - Tuning timeouts, retries, and backoff — see [configuration.md](configuration.md) + +If you have specific operational topics you’d like documented first, open an issue and tag it with `docs` and `operations`. diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md new file mode 100644 index 00000000..b98955d7 --- /dev/null +++ b/docs/operations/configuration.md @@ -0,0 +1,40 @@ +# Configuration + +Tune WebSocket behavior and retries for your environment. + +## Purpose + +- Adjust timeouts and poll intervals for send operations. +- Understand retry behavior for transient I/O failures. + +## WebSocket client settings + +The Spring WebSocket client reads the following properties (with defaults): + +- `nostr.websocket.await-timeout-ms` (default: `60000`) — Max time to await a response after send. +- `nostr.websocket.poll-interval-ms` (default: `500`) — Poll interval used during await. + +Example (application.properties): + +``` +nostr.websocket.await-timeout-ms=30000 +nostr.websocket.poll-interval-ms=250 +``` + +## Retry behavior + +WebSocket send and subscribe operations are annotated with a common retry policy: + +- Included exception: `IOException` +- Max attempts: `3` +- Backoff: initial `500ms`, multiplier `2.0` + +These values are defined in the `@NostrRetryable` annotation. To customize globally, consider: + +- Creating a custom annotation or replacing `@NostrRetryable` with your configuration. +- Providing your own `NoteService` or client wrapper that applies your retry strategy. + +## Notes + +- Timeouts apply per send; long-running subscriptions are managed separately. +- Ensure your relay endpoints’ SLAs align with chosen timeouts and backoff. diff --git a/docs/operations/logging.md b/docs/operations/logging.md new file mode 100644 index 00000000..931971fa --- /dev/null +++ b/docs/operations/logging.md @@ -0,0 +1,100 @@ +# Logging + +Configure logging for nostr-java using your preferred SLF4J backend (e.g., Logback). + +## Purpose + +- Control verbosity for `nostr.*` packages. +- Separate client transport logs from application logs. +- Capture failures for troubleshooting without overwhelming output. + +## Quick Start (Logback) + +Add `logback.xml` to your classpath (e.g., `src/main/resources/logback.xml`): + +```xml + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + +``` + +## Useful Categories + +- `nostr.api` — High-level API flows and event dispatching +- `nostr.api.client` — Dispatcher, relay registry, subscription manager +- `nostr.client.springwebsocket` — Low-level send/subscribe, retry recoveries +- `nostr.event` — Serialization, validation, decoding + +## Tips + +- Use `DEBUG` on `nostr.client.springwebsocket` to see REQ/close frames and retry recoveries. +- Use `WARN` or `ERROR` globally in production; temporarily bump `nostr.*` to `DEBUG` for investigations. + +## Spring Boot logging tips + +You can control logging without a custom Logback file using `application.properties`: + +```properties +# Reduce global noise, selectively raise nostr categories +logging.level.root=INFO +logging.level.nostr=INFO +logging.level.nostr.api=DEBUG +logging.level.nostr.client.springwebsocket=DEBUG + +# Optional: color and pattern tweaks (console) +spring.output.ansi.enabled=ALWAYS +logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + +# Write to a rolling file (Boot-managed) +logging.file.name=logs/nostr-java.log +logging.logback.rollingpolicy.max-history=7 +logging.logback.rollingpolicy.max-file-size=10MB +``` + +### JSON logging (Logback) + +For structured logs you can use Logstash Logback Encoder. + +Add the dependency (version managed by your BOM/build): + +```xml + + net.logstash.logback + logstash-logback-encoder + + +``` + +Example `logback.xml` (console JSON): + +```xml + + + + + + + + + + + + +``` + +Tip: Use MDC to correlate sends/subscriptions across logs. In pattern layouts include `%X{key}`; with JSON, add an MDC provider or use the default providers (MDC entries are emitted automatically by Logstash encoder). diff --git a/docs/operations/metrics.md b/docs/operations/metrics.md new file mode 100644 index 00000000..6d359bf8 --- /dev/null +++ b/docs/operations/metrics.md @@ -0,0 +1,193 @@ +# Metrics + +Capture simple client metrics (successes/failures) without bringing a full metrics stack. + +## Purpose + +- Track successful and failed relay sends. +- Provide hooks for plugging into your metrics/observability system. + +## Minimal counters via listener + +```java +class Counters { + final java.util.concurrent.atomic.AtomicLong sendsOk = new java.util.concurrent.atomic.AtomicLong(); + final java.util.concurrent.atomic.AtomicLong sendsFailed = new java.util.concurrent.atomic.AtomicLong(); +} + +Counters metrics = new Counters(); +NostrSpringWebSocketClient client = new NostrSpringWebSocketClient(sender); + +client.onSendFailures(failureMap -> { + // Any failure increments failed; actual successes counted after sendEvent + metrics.sendsFailed.addAndGet(failureMap.size()); +}); + +var responses = client.sendEvent(event); +metrics.sendsOk.addAndGet(responses.size()); +``` + +## Integrating with your stack + +- Micrometer: Wrap the listener to increment `Counter` instances and register with your registry. +- Prometheus: Expose counters using your HTTP endpoint and update from the listener. +- Logs: Periodically log counters as structured JSON for ingestion by your log pipeline. + +## Notes + +- Listener runs on the calling thread; keep callbacks fast and non-blocking. +- Prefer batching external calls (e.g., ship metrics on a schedule) over per-event network calls. + +## Micrometer example (with Prometheus) + +Add Micrometer + Prometheus dependencies (Spring Boot example): + +```xml + + + io.micrometer + micrometer-core + runtime + + + + + io.micrometer + micrometer-registry-prometheus + runtime + + +``` + +Register counters and a timer, then wire the failure listener: + +```java +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import nostr.api.NostrSpringWebSocketClient; +import nostr.base.IEvent; + +public class NostrMetrics { + private final Counter sendsOk; + private final Counter sendsFailed; + private final Timer sendTimer; + + public NostrMetrics(MeterRegistry registry) { + this.sendsOk = Counter.builder("nostr.sends.ok").description("Successful relay responses").register(registry); + this.sendsFailed = Counter.builder("nostr.sends.failed").description("Failed relay sends").register(registry); + this.sendTimer = Timer.builder("nostr.send.timer").description("Send latency per event").publishPercentileHistogram().register(registry); + } + + public void instrument(NostrSpringWebSocketClient client) { + // Count failures per send call (sum of relays that failed) + client.onSendFailures((Map failures) -> sendsFailed.increment(failures.size())); + } + + public List timedSend(NostrSpringWebSocketClient client, IEvent event) { + return sendTimer.record(() -> client.sendEvent(event)); + } +} +``` + +Labeling failures by relay (beware high cardinality): + +```java +client.onSendFailures(failures -> failures.forEach((relay, t) -> + Counter.builder("nostr.sends.failed") + .tag("relay", relay) // cardinality grows with number of relays + .tag("exception", t.getClass().getSimpleName()) + .register(registry) + .increment() +)); +``` + +Expose Prometheus metrics (Spring Boot): + +```properties +# application.properties +management.endpoints.web.exposure.include=prometheus +management.endpoint.prometheus.enabled=true +``` + +Navigate to `/actuator/prometheus` to scrape metrics. + +## Spring Boot wiring example + +Create a configuration that wires the client, metrics, and listener: + +```java +// src/main/java/com/example/nostr/NostrConfig.java +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import nostr.api.NostrSpringWebSocketClient; +import nostr.id.Identity; + +@Configuration +public class NostrConfig { + + @Bean + public Identity nostrIdentity() { + // Replace with a real private key or a managed Identity + return Identity.generateRandomIdentity(); + } + + @Bean + public NostrSpringWebSocketClient nostrClient(Identity identity) { + return new NostrSpringWebSocketClient(identity); + } + + @Bean + public NostrMetrics nostrMetrics(MeterRegistry registry, NostrSpringWebSocketClient client) { + NostrMetrics metrics = new NostrMetrics(registry); + metrics.instrument(client); + return metrics; + } +} +``` + +Use the instrumented client and timer in your service: + +```java +// src/main/java/com/example/nostr/NostrService.java +import java.util.List; +import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; +import nostr.api.NostrSpringWebSocketClient; +import nostr.event.impl.GenericEvent; +import nostr.base.Kind; + +@Service +@RequiredArgsConstructor +public class NostrService { + private final NostrSpringWebSocketClient client; + private final NostrMetrics metrics; + + public List publish(String content) { + GenericEvent event = GenericEvent.builder() + .pubKey(client.getSender().getPublicKey()) + .kind(Kind.TEXT_NOTE) + .content(content) + .build(); + event.update(); + client.sign(client.getSender(), event); + return metrics.timedSend(client, event); + } +} +``` + +Application properties (example): + +```properties +# Expose Prometheus endpoint +management.endpoints.web.exposure.include=prometheus +management.endpoint.prometheus.enabled=true + +# Optional: tune WebSocket timeouts +nostr.websocket.await-timeout-ms=30000 +nostr.websocket.poll-interval-ms=250 +``` diff --git a/docs/reference/nostr-java-api.md b/docs/reference/nostr-java-api.md index 4b042b1e..f36366fe 100644 --- a/docs/reference/nostr-java-api.md +++ b/docs/reference/nostr-java-api.md @@ -128,6 +128,11 @@ public Map getRelays() public void close() ``` +See also the test guides for examples and behavioral expectations: + +- API Client/Handler tests: `nostr-java-api/src/test/java/nostr/api/client/README.md` +- Client module (Spring WebSocket): `nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md` + `subscribe` opens a dedicated WebSocket per relay, returns immediately, and streams raw relay messages to the provided listener. The returned `AutoCloseable` sends a `CLOSE` command and releases resources when invoked. Because callbacks execute on the WebSocket thread, delegate heavy diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml index d14ca2f4..99b6bfa7 100644 --- a/nostr-java-api/pom.xml +++ b/nostr-java-api/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml @@ -105,5 +105,10 @@ junit-platform-launcher test + + uk.org.lidalia + slf4j-test + test + diff --git a/nostr-java-api/src/main/java/nostr/api/NostrSpringWebSocketClient.java b/nostr-java-api/src/main/java/nostr/api/NostrSpringWebSocketClient.java index daaf7603..042a2a4a 100644 --- a/nostr-java-api/src/main/java/nostr/api/NostrSpringWebSocketClient.java +++ b/nostr-java-api/src/main/java/nostr/api/NostrSpringWebSocketClient.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.HashMap; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; import lombok.Getter; @@ -217,6 +218,48 @@ public Map getRelays() { return relayRegistry.snapshotRelays(); } + /** + * Returns a map of relay name to the last send failure Throwable, if available. + * + *

When using {@link DefaultNoteService}, failures encountered during the last send on this + * thread are recorded for diagnostics. For other NoteService implementations, this returns an + * empty map. + */ + public Map getLastSendFailures() { + if (this.noteService instanceof DefaultNoteService d) { + return d.getLastFailures(); + } + return new HashMap<>(); + } + + /** + * Returns structured failure details when using {@link DefaultNoteService}. + * + * @see DefaultNoteService#getLastFailureDetails() + */ + public Map getLastSendFailureDetails() { + if (this.noteService instanceof DefaultNoteService d) { + return d.getLastFailureDetails(); + } + return new HashMap<>(); + } + + /** + * Registers a failure listener when using {@link DefaultNoteService}. No‑op otherwise. + * + *

The listener receives a relay‑name → exception map after each call to {@link + * #sendEvent(nostr.base.IEvent)}. + * + * @param listener consumer of last failures (may be {@code null} to clear) + * @return this client for chaining + */ + public NostrSpringWebSocketClient onSendFailures(java.util.function.Consumer> listener) { + if (this.noteService instanceof DefaultNoteService d) { + d.setFailureListener(listener); + } + return this; + } + public void close() throws IOException { relayRegistry.closeAll(); } diff --git a/nostr-java-api/src/main/java/nostr/api/nip57/Bolt11Util.java b/nostr-java-api/src/main/java/nostr/api/nip57/Bolt11Util.java new file mode 100644 index 00000000..f5ea2f71 --- /dev/null +++ b/nostr-java-api/src/main/java/nostr/api/nip57/Bolt11Util.java @@ -0,0 +1,67 @@ +package nostr.api.nip57; + +import java.util.Locale; + +/** Utility to parse msats from a BOLT11 invoice HRP. */ +final class Bolt11Util { + + private Bolt11Util() {} + + /** + * Parse millisatoshi amount from a BOLT11 invoice. + * + * Supports amounts encoded in the HRP using multipliers 'm', 'u', 'n', 'p'. If the invoice has + * no amount, returns -1 to indicate unknown/any amount. + * + * @param bolt11 bech32 invoice string + * @return amount in millisatoshis, or -1 if no amount present + * @throws IllegalArgumentException if the HRP is invalid or the amount cannot be parsed + */ + static long parseMsat(String bolt11) { + if (bolt11 == null || bolt11.isBlank()) { + throw new IllegalArgumentException("bolt11 invoice is required"); + } + String lower = bolt11.toLowerCase(Locale.ROOT); + int sep = lower.indexOf('1'); + if (!lower.startsWith("ln") || sep < 0) { + throw new IllegalArgumentException("Invalid BOLT11 invoice: missing HRP separator"); + } + String hrp = lower.substring(2, sep); // drop leading "ln" + // Expect network code (bc, tb, bcrt, etc.), then amount digits with optional unit + int idx = 0; + while (idx < hrp.length() && Character.isAlphabetic(hrp.charAt(idx))) idx++; + String amountPart = idx < hrp.length() ? hrp.substring(idx) : ""; + if (amountPart.isEmpty()) { + return -1; // any amount invoice + } + // Split numeric and optional unit suffix + int i = 0; + while (i < amountPart.length() && Character.isDigit(amountPart.charAt(i))) i++; + if (i == 0) { + throw new IllegalArgumentException("Invalid BOLT11 amount"); + } + long value = Long.parseLong(amountPart.substring(0, i)); + int exponent = 11; // convert BTC to msat => * 10^11 + if (i < amountPart.length()) { + char unit = amountPart.charAt(i); + exponent += switch (unit) { + case 'm' -> -3; // milliBTC + case 'u' -> -6; // microBTC + case 'n' -> -9; // nanoBTC + case 'p' -> -12; // picoBTC + default -> throw new IllegalArgumentException("Unsupported BOLT11 unit: " + unit); + }; + } + // value * 10^exponent can overflow; restrict to safe subset used in tests + java.math.BigInteger msat = java.math.BigInteger.valueOf(value); + if (exponent >= 0) { + msat = msat.multiply(java.math.BigInteger.TEN.pow(exponent)); + } else { + msat = msat.divide(java.math.BigInteger.TEN.pow(-exponent)); + } + if (msat.compareTo(java.math.BigInteger.valueOf(Long.MAX_VALUE)) > 0) { + throw new IllegalArgumentException("BOLT11 amount exceeds supported range"); + } + return msat.longValue(); + } +} diff --git a/nostr-java-api/src/main/java/nostr/api/nip57/NIP57TagFactory.java b/nostr-java-api/src/main/java/nostr/api/nip57/NIP57TagFactory.java index 15158370..c314f2a0 100644 --- a/nostr-java-api/src/main/java/nostr/api/nip57/NIP57TagFactory.java +++ b/nostr-java-api/src/main/java/nostr/api/nip57/NIP57TagFactory.java @@ -32,6 +32,10 @@ public static BaseTag description(@NonNull String description) { return new BaseTagFactory(Constants.Tag.DESCRIPTION_CODE, description).create(); } + public static BaseTag descriptionHash(@NonNull String descriptionHashHex) { + return new BaseTagFactory(Constants.Tag.DESCRIPTION_HASH_CODE, descriptionHashHex).create(); + } + public static BaseTag amount(@NonNull Number amount) { return new BaseTagFactory(Constants.Tag.AMOUNT_CODE, amount.toString()).create(); } diff --git a/nostr-java-api/src/main/java/nostr/api/nip57/NIP57ZapReceiptBuilder.java b/nostr-java-api/src/main/java/nostr/api/nip57/NIP57ZapReceiptBuilder.java index 3822327e..1c6aa13c 100644 --- a/nostr-java-api/src/main/java/nostr/api/nip57/NIP57ZapReceiptBuilder.java +++ b/nostr-java-api/src/main/java/nostr/api/nip57/NIP57ZapReceiptBuilder.java @@ -42,9 +42,14 @@ public GenericEvent build( receipt.addTag(NIP01TagFactory.pubKeyTag(zapRecipient)); try { String description = EventJsonMapper.mapper().writeValueAsString(zapRequestEvent); + // Store description (escaped) and include description_hash for validation receipt.addTag(NIP57TagFactory.description(StringEscapeUtils.escapeJson(description))); + var hash = nostr.util.NostrUtil.bytesToHex(nostr.util.NostrUtil.sha256(description.getBytes())); + receipt.addTag(NIP57TagFactory.descriptionHash(hash)); } catch (JsonProcessingException ex) { throw new EventEncodingException("Failed to encode zap receipt description", ex); + } catch (java.security.NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 algorithm not available", ex); } receipt.addTag(NIP57TagFactory.bolt11(bolt11)); receipt.addTag(NIP57TagFactory.preimage(preimage)); @@ -56,6 +61,32 @@ public GenericEvent build( .findFirst() .ifPresent(receipt::addTag); + // Validate invoice amount when available (best-effort) + try { + long invoiceMsat = Bolt11Util.parseMsat(bolt11); + if (invoiceMsat >= 0) { + var amountTag = + nostr.event.filter.Filterable.requireTagOfTypeWithCode( + nostr.event.tag.GenericTag.class, nostr.config.Constants.Tag.AMOUNT_CODE, zapRequestEvent); + String amountStr = amountTag.getAttributes().get(0).value().toString(); + long requestedMsat = Long.parseLong(amountStr); + if (requestedMsat != invoiceMsat) { + throw new IllegalArgumentException( + "Invoice amount does not match zap request amount: requested=" + + requestedMsat + + " msat, invoice=" + + invoiceMsat + + " msat"); + } + } + } catch (RuntimeException ex) { + // Preserve existing behavior for now: do not fail if amount tag is missing + // or invoice lacks amount; only propagate strict mismatches and parsing errors. + if (ex instanceof IllegalArgumentException) { + throw ex; + } + } + receipt.setCreatedAt(zapRequestEvent.getCreatedAt()); return receipt; } diff --git a/nostr-java-api/src/main/java/nostr/api/service/impl/DefaultNoteService.java b/nostr-java-api/src/main/java/nostr/api/service/impl/DefaultNoteService.java index 89cf3961..25c67fa3 100644 --- a/nostr-java-api/src/main/java/nostr/api/service/impl/DefaultNoteService.java +++ b/nostr-java-api/src/main/java/nostr/api/service/impl/DefaultNoteService.java @@ -1,21 +1,152 @@ package nostr.api.service.impl; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; import nostr.api.WebSocketClientHandler; import nostr.api.service.NoteService; import nostr.base.IEvent; /** Default implementation that dispatches notes through all WebSocket clients. */ +@Slf4j public class DefaultNoteService implements NoteService { + + private final ThreadLocal> lastFailures = + ThreadLocal.withInitial(HashMap::new); + private final ThreadLocal> lastFailureDetails = + ThreadLocal.withInitial(HashMap::new); + private java.util.function.Consumer> failureListener; + + /** + * Returns a snapshot of relay send failures recorded during the last {@code send} call on the + * current thread. + * + *

The map key is the relay name as registered in the client; the value is the exception thrown + * while attempting to send to that relay. A best effort is made to continue sending to other + * relays even if one relay fails. + * + * @return a copy of the last failure map; empty if the last send had no failures + */ + public Map getLastFailures() { + return new HashMap<>(lastFailures.get()); + } + + /** + * Returns structured failure details for the last {@code send} call on this thread. + * + *

Each entry includes timing, relay name and URI, the thrown exception class/message and the + * root cause class/message (if any). Use this for richer diagnostics and logging. + * + * @return a copy of the last failure details; empty if the last send had no failures + */ + public Map getLastFailureDetails() { + return new HashMap<>(lastFailureDetails.get()); + } + + /** + * Registers a listener that receives the per‑relay failures map after each {@code send} call. + * + *

The callback is invoked with a map of relay name to Throwable for relays that failed during + * the last send attempt. The listener runs on the calling thread and exceptions thrown by the + * listener are ignored to avoid impacting the main flow. + * + * @param listener consumer of the failure map; may be {@code null} to clear + */ + public void setFailureListener(java.util.function.Consumer> listener) { + this.failureListener = listener; + } + @Override public List send( @NonNull IEvent event, @NonNull Map clients) { - return clients.values().stream() - .map(client -> client.sendEvent(event)) - .flatMap(List::stream) - .distinct() - .toList(); + ArrayList responses = new ArrayList<>(); + Map failures = new HashMap<>(); + Map details = new HashMap<>(); + RuntimeException lastFailure = null; + + for (Map.Entry entry : clients.entrySet()) { + String relayName = entry.getKey(); + WebSocketClientHandler client = entry.getValue(); + try { + responses.addAll(client.sendEvent(event)); + } catch (RuntimeException e) { + failures.put(relayName, e); + details.put(relayName, FailureInfo.from(relayName, client.getRelayUri().toString(), e)); + lastFailure = e; // capture and continue to attempt other relays + log.warn("Failed to send event on relay {}: {}", relayName, e.getMessage()); + } + } + + lastFailures.set(failures); + lastFailureDetails.set(details); + if (failureListener != null && !failures.isEmpty()) { + try { failureListener.accept(new HashMap<>(failures)); } catch (Exception ignored) {} + } + + if (responses.isEmpty() && lastFailure != null) { + throw lastFailure; + } + return responses.stream().distinct().toList(); + } + + /** + * Provides structured information about a relay send failure. + */ + public static final class FailureInfo { + public final long timestampEpochMillis; + public final String relayName; + public final String relayUri; + public final String exceptionClass; + public final String message; + public final String rootCauseClass; + public final String rootCauseMessage; + + private FailureInfo( + long ts, + String relayName, + String relayUri, + String cls, + String msg, + String rootCls, + String rootMsg) { + this.timestampEpochMillis = ts; + this.relayName = relayName; + this.relayUri = relayUri; + this.exceptionClass = cls; + this.message = msg; + this.rootCauseClass = rootCls; + this.rootCauseMessage = rootMsg; + } + + private static Throwable root(Throwable t) { + Throwable r = t; + while (r.getCause() != null && r.getCause() != r) { + r = r.getCause(); + } + return r; + } + + /** + * Create a {@link FailureInfo} from a relay identity and a thrown exception. + * + * @param relayName human‑readable name configured by the client + * @param relayUri websocket URI string of the relay + * @param t the thrown exception + * @return a populated {@link FailureInfo} + */ + public static FailureInfo from(String relayName, String relayUri, Throwable t) { + Throwable r = root(t); + return new FailureInfo( + java.time.Instant.now().toEpochMilli(), + relayName, + relayUri, + t.getClass().getName(), + String.valueOf(t.getMessage()), + r.getClass().getName(), + String.valueOf(r.getMessage())); + } } } diff --git a/nostr-java-api/src/main/java/nostr/config/Constants.java b/nostr-java-api/src/main/java/nostr/config/Constants.java index 044b8f4f..bb286566 100644 --- a/nostr-java-api/src/main/java/nostr/config/Constants.java +++ b/nostr-java-api/src/main/java/nostr/config/Constants.java @@ -222,6 +222,7 @@ private Tag() {} public static final String BOLT11_CODE = "bolt11"; public static final String PREIMAGE_CODE = "preimage"; public static final String DESCRIPTION_CODE = "description"; + public static final String DESCRIPTION_HASH_CODE = "description_hash"; public static final String ZAP_CODE = "zap"; public static final String RECIPIENT_PUBKEY_CODE = "P"; public static final String MINT_CODE = "mint"; diff --git a/nostr-java-api/src/test/java/nostr/api/TestHandlerFactory.java b/nostr-java-api/src/test/java/nostr/api/TestHandlerFactory.java new file mode 100644 index 00000000..1228e42b --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/TestHandlerFactory.java @@ -0,0 +1,34 @@ +package nostr.api; + +import java.util.HashMap; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import lombok.NonNull; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; + +/** + * Test-only factory to construct {@link WebSocketClientHandler} while staying inside the + * {@code nostr.api} package to access package-private constructor. + */ +public final class TestHandlerFactory { + private TestHandlerFactory() {} + + public static WebSocketClientHandler create( + @NonNull String relayName, + @NonNull String relayUri, + @NonNull SpringWebSocketClient client, + @NonNull Function requestClientFactory, + @NonNull WebSocketClientFactory clientFactory) throws ExecutionException, InterruptedException { + return new WebSocketClientHandler( + relayName, + new RelayUri(relayUri), + client, + new HashMap<>(), + requestClientFactory, + clientFactory); + } +} + diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherEnsureClientsTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherEnsureClientsTest.java new file mode 100644 index 00000000..f559271f --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherEnsureClientsTest.java @@ -0,0 +1,42 @@ +package nostr.api.client; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.util.List; +import nostr.base.SubscriptionId; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; + +/** Verifies ensureRequestClients() is invoked per dispatcher call as expected. */ +public class NostrRequestDispatcherEnsureClientsTest { + + @Test + void ensureCalledOnceForSingleFilter() { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler handler = mock(WebSocketClientHandler.class); + when(registry.requestHandlers(eq(SubscriptionId.of("sub-1")))).thenReturn(List.of(handler)); + NostrRequestDispatcher dispatcher = new NostrRequestDispatcher(registry); + + dispatcher.sendRequest(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-1"); + verify(registry, times(1)).ensureRequestClients(eq(SubscriptionId.of("sub-1"))); + } + + @Test + void ensureCalledPerFilterForListVariant() { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler handler = mock(WebSocketClientHandler.class); + when(registry.requestHandlers(eq(SubscriptionId.of("sub-2")))).thenReturn(List.of(handler)); + NostrRequestDispatcher dispatcher = new NostrRequestDispatcher(registry); + + List list = List.of( + new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), + new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)) + ); + dispatcher.sendRequest(list, "sub-2"); + verify(registry, times(2)).ensureRequestClients(eq(SubscriptionId.of("sub-2"))); + } +} + diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherTest.java new file mode 100644 index 00000000..00f79d5f --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrRequestDispatcherTest.java @@ -0,0 +1,61 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.util.List; +import nostr.base.SubscriptionId; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; + +/** Tests for NostrRequestDispatcher multi-filter dispatch and aggregation. */ +public class NostrRequestDispatcherTest { + + @Test + void multiFilterDispatchAggregatesResponses() { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler handler = mock(WebSocketClientHandler.class); + + when(registry.requestHandlers(eq(SubscriptionId.of("sub-Z")))).thenReturn(List.of(handler)); + doNothing().when(registry).ensureRequestClients(eq(SubscriptionId.of("sub-Z"))); + + when(handler.sendRequest(any(Filters.class), eq(SubscriptionId.of("sub-Z")))) + .thenReturn(List.of("R1")) + .thenReturn(List.of("R2")); + + NostrRequestDispatcher dispatcher = new NostrRequestDispatcher(registry); + List list = + List.of(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), + new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE))); + + var out = dispatcher.sendRequest(list, "sub-Z"); + assertEquals(2, out.size()); + // ensure each filter triggered a send on handler + verify(handler, times(2)).sendRequest(any(Filters.class), eq(SubscriptionId.of("sub-Z"))); + } + + @Test + void multiFilterDispatchDeduplicatesResponses() { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler handler = mock(WebSocketClientHandler.class); + when(registry.requestHandlers(eq(SubscriptionId.of("sub-D")))).thenReturn(List.of(handler)); + doNothing().when(registry).ensureRequestClients(eq(SubscriptionId.of("sub-D"))); + + // Return the same response for both filters; expect distinct aggregation + when(handler.sendRequest(any(Filters.class), eq(SubscriptionId.of("sub-D")))) + .thenReturn(List.of("DUP")) + .thenReturn(List.of("DUP")); + + NostrRequestDispatcher dispatcher = new NostrRequestDispatcher(registry); + List list = + List.of(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), + new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE))); + + var out = dispatcher.sendRequest(list, "sub-D"); + assertEquals(1, out.size()); + verify(handler, times(2)).sendRequest(any(Filters.class), eq(SubscriptionId.of("sub-D"))); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientCloseLoggingTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientCloseLoggingTest.java new file mode 100644 index 00000000..ddc92f74 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientCloseLoggingTest.java @@ -0,0 +1,87 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import lombok.NonNull; +import nostr.api.NostrSpringWebSocketClient; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.id.Identity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import uk.org.lidalia.slf4jtest.TestLogger; +import uk.org.lidalia.slf4jtest.TestLoggerFactory; + +/** Verifies default error listener logs WARN lines when close path encounters exceptions. */ +public class NostrSpringWebSocketClientCloseLoggingTest { + + private final TestLogger logger = TestLoggerFactory.getTestLogger(NostrSpringWebSocketClient.class); + + static class TestClient extends NostrSpringWebSocketClient { + private final WebSocketClientHandler handler; + TestClient(Identity sender, WebSocketClientHandler handler) { super(sender); this.handler = handler; } + + @Override + protected WebSocketClientHandler newWebSocketClientHandler(String relayName, RelayUri relayUri) + throws ExecutionException, InterruptedException { + return handler; + } + } + + @AfterEach + void cleanup() { TestLoggerFactory.clear(); } + + @Test + void logsWarnsOnCloseErrors() throws Exception { + // Prepare a handler with mocked Spring client throwing on close + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + AutoCloseable delegate = mock(AutoCloseable.class); + AutoCloseable closeFrame = mock(AutoCloseable.class); + when(client.subscribe(any(nostr.event.message.ReqMessage.class), any(), any(), any())).thenReturn(delegate); + when(client.subscribe(any(nostr.event.message.CloseMessage.class), any(), any(), any())).thenReturn(closeFrame); + doThrow(new IOException("cf")).when(closeFrame).close(); + doThrow(new RuntimeException("del")).when(delegate).close(); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + WebSocketClientHandler handler = + new WebSocketClientHandler( + "relay-1", + new RelayUri("wss://relay1"), + client, + new HashMap<>(), + reqFactory, + factory); + + Identity sender = Identity.generateRandomIdentity(); + TestClient testClient = new TestClient(sender, handler); + testClient.setRelays(Map.of("r1", "wss://relay1")); + + AutoCloseable h = testClient.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-close-log", s -> {}); + try { + try { + h.close(); + } catch (IOException ignored) {} + boolean found = logger.getLoggingEvents().stream() + .anyMatch(e -> e.getLevel().toString().equals("WARN") + && e.getMessage().contains("Subscription error for {} on relays {}") + && e.getArguments().size() == 2 + && String.valueOf(e.getArguments().get(0)).contains("sub-close-log") + && String.valueOf(e.getArguments().get(1)).contains("relay")); + assertTrue(found); + } finally { + try { h.close(); } catch (Exception ignored) {} + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientHandlerIntegrationTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientHandlerIntegrationTest.java new file mode 100644 index 00000000..04c047e1 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientHandlerIntegrationTest.java @@ -0,0 +1,49 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import lombok.NonNull; +import nostr.api.NostrSpringWebSocketClient; +import nostr.base.RelayUri; +import nostr.client.WebSocketClientFactory; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** Wires NostrSpringWebSocketClient to a mocked handler and verifies subscribe/close flow. */ +public class NostrSpringWebSocketClientHandlerIntegrationTest { + + static class TestClient extends NostrSpringWebSocketClient { + private final WebSocketClientHandler handler; + TestClient(Identity sender, WebSocketClientHandler handler) { super(sender); this.handler = handler; } + + @Override + protected WebSocketClientHandler newWebSocketClientHandler(String relayName, RelayUri relayUri) + throws ExecutionException, InterruptedException { + return handler; + } + } + + @Test + void clientSubscribeDelegatesToHandlerAndCloseClosesHandle() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + WebSocketClientHandler handler = mock(WebSocketClientHandler.class); + AutoCloseable handle = mock(AutoCloseable.class); + when(handler.subscribe(any(), anyString(), any(Consumer.class), any())).thenReturn(handle); + + TestClient client = new TestClient(sender, handler); + client.setRelays(Map.of("r1", "wss://relay1")); + + AutoCloseable h = client.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-i", s -> {}); + verify(handler, times(1)).subscribe(any(), anyString(), any(Consumer.class), any()); + + h.close(); + verify(handle, times(1)).close(); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientLoggingTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientLoggingTest.java new file mode 100644 index 00000000..f41c8033 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientLoggingTest.java @@ -0,0 +1,49 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import nostr.api.NostrSpringWebSocketClient; +import nostr.api.integration.support.FakeWebSocketClientFactory; +import nostr.api.service.impl.DefaultNoteService; +import nostr.base.Kind; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.id.Identity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import uk.org.lidalia.slf4jtest.LoggingEvent; +import uk.org.lidalia.slf4jtest.TestLogger; +import uk.org.lidalia.slf4jtest.TestLoggerFactory; + +/** Verifies default error listener path emits a WARN log entry. */ +public class NostrSpringWebSocketClientLoggingTest { + + private final TestLogger logger = TestLoggerFactory.getTestLogger(NostrSpringWebSocketClient.class); + + @AfterEach + void cleanup() { TestLoggerFactory.clear(); } + + @Test + void defaultErrorListenerEmitsWarnLog() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + client.setRelays(Map.of("relay", "wss://relay.example.com")); + AutoCloseable handle = client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-log", s -> {}); + try { + factory.get("wss://relay.example.com").emitError(new RuntimeException("log-me")); + boolean found = logger.getLoggingEvents().stream() + .anyMatch(e -> e.getLevel().toString().equals("WARN") + && e.getMessage().contains("Subscription error for {} on relays {}") + && e.getArguments().size() == 2 + && String.valueOf(e.getArguments().get(0)).contains("sub-log") + && String.valueOf(e.getArguments().get(1)).contains("relay")); + assertTrue(found); + } finally { + handle.close(); + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientRelaysTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientRelaysTest.java new file mode 100644 index 00000000..a95250ba --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientRelaysTest.java @@ -0,0 +1,26 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import nostr.api.NostrSpringWebSocketClient; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** Verifies getRelays returns the snapshot of relay names to URIs. */ +public class NostrSpringWebSocketClientRelaysTest { + + @Test + void getRelaysReflectsRegistration() { + Identity sender = Identity.generateRandomIdentity(); + NostrSpringWebSocketClient client = new NostrSpringWebSocketClient(sender); + client.setRelays(Map.of( + "r1", "wss://relay1", + "r2", "wss://relay2")); + + Map snapshot = client.getRelays(); + assertEquals(2, snapshot.size()); + assertEquals("wss://relay1", snapshot.get("r1")); + assertEquals("wss://relay2", snapshot.get("r2")); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientSubscribeLoggingTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientSubscribeLoggingTest.java new file mode 100644 index 00000000..81db4987 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSpringWebSocketClientSubscribeLoggingTest.java @@ -0,0 +1,78 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.api.NostrSpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.id.Identity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import uk.org.lidalia.slf4jtest.TestLogger; +import uk.org.lidalia.slf4jtest.TestLoggerFactory; + +/** Verifies default error listener emits WARN logs when subscribe path throws. */ +public class NostrSpringWebSocketClientSubscribeLoggingTest { + + private final TestLogger logger = TestLoggerFactory.getTestLogger(NostrSpringWebSocketClient.class); + + static class TestClient extends NostrSpringWebSocketClient { + private final WebSocketClientHandler handler; + TestClient(Identity sender, WebSocketClientHandler handler) { super(sender); this.handler = handler; } + @Override + protected WebSocketClientHandler newWebSocketClientHandler(String relayName, RelayUri relayUri) + throws ExecutionException, InterruptedException { + return handler; + } + } + + @AfterEach + void cleanup() { TestLoggerFactory.clear(); } + + @Test + void logsWarnOnSubscribeFailureWithDefaultErrorListener() throws Exception { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + // Throw on subscribe to simulate transport failure + when(client.subscribe(any(nostr.event.message.ReqMessage.class), any(), any(), any())) + .thenThrow(new IOException("subscribe-io")); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + WebSocketClientHandler handler = + new WebSocketClientHandler( + "relay-1", + new RelayUri("wss://relay1"), + client, + new HashMap<>(), + reqFactory, + factory); + + Identity sender = Identity.generateRandomIdentity(); + TestClient testClient = new TestClient(sender, handler); + testClient.setRelays(Map.of("r1", "wss://relay1")); + + try { + testClient.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-warn", s -> {}); + } catch (RuntimeException ignored) { + // default error listener warns; the exception is rethrown by handler subscribe path + } + boolean found = logger.getLoggingEvents().stream() + .anyMatch(e -> e.getLevel().toString().equals("WARN") + && e.getMessage().contains("Subscription error on relay {} for {}") + && e.getArguments().size() == 2 + && String.valueOf(e.getArguments().get(0)).contains("relay-1") + && String.valueOf(e.getArguments().get(1)).contains("sub-warn")); + assertTrue(found); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/NostrSubscriptionManagerCloseTest.java b/nostr-java-api/src/test/java/nostr/api/client/NostrSubscriptionManagerCloseTest.java new file mode 100644 index 00000000..4cbf064f --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/NostrSubscriptionManagerCloseTest.java @@ -0,0 +1,66 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Tests close semantics and error aggregation in NostrSubscriptionManager. */ +public class NostrSubscriptionManagerCloseTest { + + @Test + // When closing multiple handles, IOException takes precedence; errors are reported to consumer. + void closesAllHandlesAndAggregatesErrors() throws Exception { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler h1 = mock(WebSocketClientHandler.class); + WebSocketClientHandler h2 = mock(WebSocketClientHandler.class); + when(registry.baseHandlers()).thenReturn(List.of(h1, h2)); + + AutoCloseable c1 = mock(AutoCloseable.class); + AutoCloseable c2 = mock(AutoCloseable.class); + when(h1.subscribe(any(), anyString(), any(), any())).thenReturn(c1); + when(h2.subscribe(any(), anyString(), any(), any())).thenReturn(c2); + + NostrSubscriptionManager mgr = new NostrSubscriptionManager(registry); + AtomicInteger errorCount = new AtomicInteger(); + Consumer errorConsumer = t -> errorCount.incrementAndGet(); + AutoCloseable handle = mgr.subscribe(new nostr.event.filter.Filters(new nostr.event.filter.KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "subX", s -> {}, errorConsumer); + + doThrow(new IOException("iofail")).when(c1).close(); + doThrow(new RuntimeException("boom")).when(c2).close(); + + IOException thrown = assertThrows(IOException.class, handle::close); + assertEquals("iofail", thrown.getMessage()); + // Both errors reported + assertEquals(2, errorCount.get()); + } + + @Test + // If subscribe fails mid-iteration, previously acquired handles are closed and error reported. + void subscribeFailureClosesAcquiredHandles() throws Exception { + NostrRelayRegistry registry = mock(NostrRelayRegistry.class); + WebSocketClientHandler h1 = mock(WebSocketClientHandler.class); + WebSocketClientHandler h2 = mock(WebSocketClientHandler.class); + when(registry.baseHandlers()).thenReturn(List.of(h1, h2)); + + AutoCloseable c1 = mock(AutoCloseable.class); + when(h1.subscribe(any(), anyString(), any(), any())).thenReturn(c1); + when(h2.subscribe(any(), anyString(), any(), any())).thenThrow(new RuntimeException("sub-fail")); + + NostrSubscriptionManager mgr = new NostrSubscriptionManager(registry); + AtomicInteger errorCount = new AtomicInteger(); + Consumer errorConsumer = t -> errorCount.incrementAndGet(); + + assertThrows(RuntimeException.class, () -> + mgr.subscribe(new nostr.event.filter.Filters(new nostr.event.filter.KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "subY", s -> {}, errorConsumer)); + + // First handle should be closed due to failure in second subscribe + verify(c1, times(1)).close(); + assertEquals(1, errorCount.get()); + } +} + diff --git a/nostr-java-api/src/test/java/nostr/api/client/README.md b/nostr-java-api/src/test/java/nostr/api/client/README.md new file mode 100644 index 00000000..28b331fa --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/README.md @@ -0,0 +1,20 @@ +# Client/Handler Test Suite + +This package contains tests for the API client and the internal WebSocket handler. + +## Structure + +- `NostrSpringWebSocketClient*` — Tests for high-level client behavior (logging, relays, integration). +- `WebSocketHandler*` — Tests for internal handler semantics: + - `SendCloseFrame` — Ensures CLOSE frame is sent on handle close. + - `CloseSequencing` — Verifies close ordering and exception handling. + - `CloseIdempotent` — Double close does not throw. + - `SendRequest` — Encodes correct subscription id; multi-sub tests. + - `RequestError` — IOException wrapping as RuntimeException. +- `NostrRequestDispatcher*` — Tests REQ dispatch across handlers including de-duplication and ensureClient calls. +- `NostrSubscriptionManager*` — Tests subscribe lifecycle and close error aggregation. + +## Notes + +- `nostr.api.TestHandlerFactory` is used to instantiate a `WebSocketClientHandler` from outside the `nostr.api` package while preserving access to its package-private constructor. +- Logging assertions use `slf4j-test` to capture and inspect log events. diff --git a/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseIdempotentTest.java b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseIdempotentTest.java new file mode 100644 index 00000000..d5a8307c --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseIdempotentTest.java @@ -0,0 +1,43 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; + +/** Verifies calling close twice on a subscription handle does not throw. */ +public class WebSocketHandlerCloseIdempotentTest { + + @Test + void doubleCloseDoesNotThrow() throws ExecutionException, InterruptedException, IOException { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + AutoCloseable delegate = mock(AutoCloseable.class); + AutoCloseable closeFrame = mock(AutoCloseable.class); + when(client.subscribe(any(nostr.event.message.ReqMessage.class), any(), any(), any())) + .thenReturn(delegate); + when(client.subscribe(any(nostr.event.message.CloseMessage.class), any(), any(), any())) + .thenReturn(closeFrame); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-1", "wss://relay1", client, reqFactory, factory); + + AutoCloseable handle = handler.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-dup", s -> {}, t -> {}); + assertDoesNotThrow(handle::close); + // Second close should also not throw + assertDoesNotThrow(handle::close); + verify(client, atLeastOnce()).close(); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseSequencingTest.java b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseSequencingTest.java new file mode 100644 index 00000000..5a0bd6b8 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerCloseSequencingTest.java @@ -0,0 +1,92 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +/** Ensures CLOSE frame is sent before delegate and client close, even on exceptions. */ +public class WebSocketHandlerCloseSequencingTest { + + @Test + void closeOrderIsCloseFrameThenDelegateThenClient() throws Exception { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + AutoCloseable delegate = mock(AutoCloseable.class); + AutoCloseable closeFrame = mock(AutoCloseable.class); + when(client.subscribe(any(nostr.event.message.ReqMessage.class), any(), any(), any())) + .thenReturn(delegate); + when(client.subscribe(any(nostr.event.message.CloseMessage.class), any(), any(), any())) + .thenReturn(closeFrame); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-1", "wss://relay1", client, reqFactory, factory); + + AutoCloseable handle = handler.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-789", s -> {}, t -> {}); + handle.close(); + + InOrder inOrder = inOrder(closeFrame, delegate, client); + inOrder.verify(closeFrame, times(1)).close(); + inOrder.verify(delegate, times(1)).close(); + inOrder.verify(client, times(1)).close(); + } + + @Test + void exceptionsStillAttemptAllClosesAndThrowFirstIo() throws Exception { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + AutoCloseable delegate = mock(AutoCloseable.class); + AutoCloseable closeFrame = mock(AutoCloseable.class); + when(client.subscribe(any(nostr.event.message.ReqMessage.class), any(), any(), any())) + .thenReturn(delegate); + when(client.subscribe(any(nostr.event.message.CloseMessage.class), any(), any(), any())) + .thenReturn(closeFrame); + + doThrow(new IOException("frame-io")).when(closeFrame).close(); + doThrow(new RuntimeException("del-boom")).when(delegate).close(); + doThrow(new IOException("client-io")).when(client).close(); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-1", "wss://relay1", client, reqFactory, factory); + + AutoCloseable handle = handler.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-err", s -> {}, t -> {}); + IOException thrown = assertEqualsType(IOException.class, () -> handle.close()); + assertEquals("frame-io", thrown.getMessage()); + + // All closes attempted even on exceptions + verify(closeFrame, times(1)).close(); + verify(delegate, times(1)).close(); + verify(client, times(1)).close(); + } + + private static T assertEqualsType(Class type, Executable executable) { + try { + executable.exec(); + throw new AssertionError("Expected exception: " + type.getSimpleName()); + } catch (Throwable t) { + if (type.isInstance(t)) { + return type.cast(t); + } + throw new AssertionError("Unexpected exception type: " + t.getClass(), t); + } + } + + @FunctionalInterface + private interface Executable { void exec() throws Exception; } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerRequestErrorTest.java b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerRequestErrorTest.java new file mode 100644 index 00000000..e06bd8d7 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerRequestErrorTest.java @@ -0,0 +1,36 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; + +/** Ensures sendRequest wraps IOExceptions as RuntimeException with context. */ +public class WebSocketHandlerRequestErrorTest { + + @Test + void sendRequestWrapsIOException() throws ExecutionException, InterruptedException, IOException { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + when(client.send(any(nostr.event.message.ReqMessage.class))).thenThrow(new IOException("net-broken")); + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-x", "wss://relayx", client, reqFactory, factory); + + Filters filters = new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)); + RuntimeException ex = assertThrows(RuntimeException.class, () -> handler.sendRequest(filters, SubscriptionId.of("sub-err"))); + assertEquals("Failed to send request", ex.getMessage()); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendCloseFrameTest.java b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendCloseFrameTest.java new file mode 100644 index 00000000..a824e23a --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendCloseFrameTest.java @@ -0,0 +1,47 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.event.message.CloseMessage; +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** Verifies WebSocketClientHandler close sends CLOSE frame and closes client. */ +public class WebSocketHandlerSendCloseFrameTest { + + @Test + void closeSendsCloseFrameAndClosesClient() throws ExecutionException, InterruptedException, IOException { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + when(client.subscribe(any(ReqMessage.class), any(), any(), any())).thenReturn(() -> {}); + when(client.subscribe(any(CloseMessage.class), any(), any(), any())).thenReturn(() -> {}); + + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-1", "wss://relay1", client, reqFactory, factory); + + AutoCloseable handle = handler.subscribe(new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)), "sub-123", s -> {}, t -> {}); + + // Close and verify a CLOSE frame was sent + handle.close(); + ArgumentCaptor captor = ArgumentCaptor.forClass(CloseMessage.class); + verify(client, atLeastOnce()).subscribe(captor.capture(), any(), any(), any()); + boolean closeSent = captor.getAllValues().stream().anyMatch(m -> m.encode().contains("\"CLOSE\",\"sub-123\"")); + assertTrue(closeSent); + verify(client, atLeastOnce()).close(); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendRequestTest.java b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendRequestTest.java new file mode 100644 index 00000000..777fd07b --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/client/WebSocketHandlerSendRequestTest.java @@ -0,0 +1,44 @@ +package nostr.api.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import nostr.base.RelayUri; +import nostr.base.SubscriptionId; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.SpringWebSocketClient; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** Tests sendRequest for multiple sub ids and verifying subscription id usage. */ +public class WebSocketHandlerSendRequestTest { + + @Test + void sendsReqWithGivenSubscriptionId() throws ExecutionException, InterruptedException, IOException { + SpringWebSocketClient client = mock(SpringWebSocketClient.class); + when(client.send(any(nostr.event.message.ReqMessage.class))).thenReturn(List.of("OK")); + WebSocketClientFactory factory = mock(WebSocketClientFactory.class); + Function reqFactory = k -> client; + + nostr.api.WebSocketClientHandler handler = + nostr.api.TestHandlerFactory.create( + "relay-1", "wss://relay1", client, reqFactory, factory); + + Filters filters = new Filters(new KindFilter<>(nostr.base.Kind.TEXT_NOTE)); + handler.sendRequest(filters, SubscriptionId.of("sub-A")); + handler.sendRequest(filters, SubscriptionId.of("sub-B")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(nostr.event.message.ReqMessage.class); + verify(client, times(2)).send(captor.capture()); + assertTrue(captor.getAllValues().get(0).encode().contains("\"sub-A\"")); + assertTrue(captor.getAllValues().get(1).encode().contains("\"sub-B\"")); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/BaseRelayIntegrationTest.java b/nostr-java-api/src/test/java/nostr/api/integration/BaseRelayIntegrationTest.java index 202548dd..cbcd62f3 100644 --- a/nostr-java-api/src/test/java/nostr/api/integration/BaseRelayIntegrationTest.java +++ b/nostr-java-api/src/test/java/nostr/api/integration/BaseRelayIntegrationTest.java @@ -6,12 +6,19 @@ import org.junit.jupiter.api.BeforeAll; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +/** + * Base class for Testcontainers-backed relay integration tests. + * + * Disabled automatically when the system property `noDocker=true` is set (e.g. CI without Docker). + */ +@DisabledIfSystemProperty(named = "noDocker", matches = "true") @Testcontainers public abstract class BaseRelayIntegrationTest { diff --git a/nostr-java-api/src/test/java/nostr/api/integration/MultiRelayIT.java b/nostr-java-api/src/test/java/nostr/api/integration/MultiRelayIT.java new file mode 100644 index 00000000..ea6330c4 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/MultiRelayIT.java @@ -0,0 +1,154 @@ +package nostr.api.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import nostr.api.NostrSpringWebSocketClient; +import nostr.api.integration.support.FakeWebSocketClient; +import nostr.api.integration.support.FakeWebSocketClientFactory; +import nostr.api.service.impl.DefaultNoteService; +import nostr.base.Kind; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** + * Integration tests covering multi-relay behavior using a fake WebSocket client factory. + */ +public class MultiRelayIT { + + /** + * Verifies that sending an event broadcasts to all configured relays and returns responses from + * each relay. + */ + @Test + void testBroadcastToMultipleRelays() { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = + Map.of( + "relay1", "wss://relay1.example.com", + "relay2", "wss://relay2.example.com", + "relay3", "wss://relay3.example.com"); + client.setRelays(relays); + + GenericEvent event = + GenericEvent.builder() + .pubKey(sender.getPublicKey()) + .kind(Kind.TEXT_NOTE) + .content("hello nostr") + .build(); + event.update(); + client.sign(sender, event); + + List responses = client.sendEvent(event); + assertEquals(3, responses.size(), "Should receive one response per relay"); + assertTrue(responses.contains("OK:wss://relay1.example.com")); + assertTrue(responses.contains("OK:wss://relay2.example.com")); + assertTrue(responses.contains("OK:wss://relay3.example.com")); + + // Also check each fake recorded the payload + for (String uri : relays.values()) { + FakeWebSocketClient fake = factory.get(uri); + assertTrue( + fake.getSentPayloads().stream().anyMatch(p -> p.contains("EVENT")), + "Relay should have been sent an EVENT message: " + uri); + } + } + + /** + * Ensures that if one relay fails to send, other relay responses are still returned and + * the failure is recorded for diagnostics. + */ + @Test + void testRelayFailoverReturnsAvailableResponses() { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + DefaultNoteService noteService = new DefaultNoteService(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, noteService, factory); + + Map relays = + Map.of( + "relayA", "wss://relayA.example.com", + "relayB", "wss://relayB.example.com"); + client.setRelays(relays); + + GenericEvent event = + GenericEvent.builder() + .pubKey(sender.getPublicKey()) + .kind(Kind.TEXT_NOTE) + .content("broadcast with partial availability") + .build(); + event.update(); + client.sign(sender, event); + + // Simulate relayB failure + FakeWebSocketClient relayB = factory.get("wss://relayB.example.com"); + try { relayB.close(); } catch (Exception ignored) {} + + List responses = client.sendEvent(event); + assertEquals(1, responses.size()); + assertTrue(responses.contains("OK:wss://relayA.example.com")); + + Map failures = noteService.getLastFailures(); + assertTrue(failures.containsKey("relayB")); + + // Also visible via client accessors + Map clientFailures = client.getLastSendFailures(); + assertTrue(clientFailures.containsKey("relayB")); + + // Structured details available as well + var details = client.getLastSendFailureDetails(); + assertTrue(details.containsKey("relayB")); + } + + /** + * Verifies that a REQ is sent per relay and contains the subscription id. + */ + @Test + void testCrossRelayEventRetrievalViaReq() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = + Map.of( + "relay1", "wss://relay1.example.com", + "relay2", "wss://relay2.example.com"); + client.setRelays(relays); + + // Open a subscription (so request clients exist) and then send a REQ + var received = new CopyOnWriteArrayList(); + var handle = + client.subscribe( + new nostr.event.filter.Filters(new nostr.event.filter.KindFilter<>(Kind.TEXT_NOTE)), + "sub-123", + received::add); + try { + List reqResponses = + client.sendRequest( + new nostr.event.filter.Filters( + new nostr.event.filter.KindFilter<>(Kind.TEXT_NOTE)), + "sub-123"); + assertEquals(2, reqResponses.size()); + + // Check REQ payloads captured by fakes + for (String uri : relays.values()) { + FakeWebSocketClient fake = factory.get(uri); + assertTrue( + fake.getSentPayloads().stream().anyMatch(p -> p.contains("\"REQ\",\"sub-123\"")), + "Relay should have been sent a REQ for sub-123: " + uri); + } + } finally { + handle.close(); + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/SubscriptionLifecycleIT.java b/nostr-java-api/src/test/java/nostr/api/integration/SubscriptionLifecycleIT.java new file mode 100644 index 00000000..0a2de93b --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/SubscriptionLifecycleIT.java @@ -0,0 +1,192 @@ +package nostr.api.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import nostr.api.NostrSpringWebSocketClient; +import nostr.api.integration.support.FakeWebSocketClient; +import nostr.api.integration.support.FakeWebSocketClientFactory; +import nostr.api.service.impl.DefaultNoteService; +import nostr.base.Kind; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for subscription lifecycle using a fake WebSocket client. + */ +public class SubscriptionLifecycleIT { + + /** + * Validates that subscription listeners receive messages emitted by all relays. + */ + @Test + void testSubscriptionReceivesNewEvents() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = + Map.of( + "relay1", "wss://relay1.example.com", + "relay2", "wss://relay2.example.com"); + client.setRelays(relays); + + List received = new CopyOnWriteArrayList<>(); + AutoCloseable handle = + client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-evt", received::add); + try { + // Simulate inbound events from both relays + factory.get("wss://relay1.example.com").emit("EVENT from relay1"); + factory.get("wss://relay2.example.com").emit("EVENT from relay2"); + + // Both messages should be received + assertTrue(received.stream().anyMatch(s -> s.contains("relay1"))); + assertTrue(received.stream().anyMatch(s -> s.contains("relay2"))); + } finally { + handle.close(); + } + } + + /** + * Validates concurrent subscriptions receive their respective messages without interference. + */ + @Test + void testConcurrentSubscriptions() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = + Map.of( + "relay1", "wss://relay1.example.com", + "relay2", "wss://relay2.example.com"); + client.setRelays(relays); + + List s1 = new CopyOnWriteArrayList<>(); + List s2 = new CopyOnWriteArrayList<>(); + + AutoCloseable h1 = client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-A", s1::add); + AutoCloseable h2 = client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-B", s2::add); + try { + factory.get("wss://relay1.example.com").emit("[\"EVENT\",\"sub-A\",{}]"); + factory.get("wss://relay2.example.com").emit("[\"EVENT\",\"sub-B\",{}]"); + + assertTrue(s1.stream().anyMatch(m -> m.contains("sub-A"))); + assertTrue(s2.stream().anyMatch(m -> m.contains("sub-B"))); + } finally { + h1.close(); + h2.close(); + } + } + + /** + * Errors emitted by the underlying client should propagate to the provided error listener. + */ + @Test + void testErrorPropagationToListener() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = Map.of("relay", "wss://relay.example.com"); + client.setRelays(relays); + + List errors = new CopyOnWriteArrayList<>(); + AutoCloseable handle = + client.subscribe( + new Filters(new KindFilter<>(Kind.TEXT_NOTE)), + "sub-err", + m -> {}, + errors::add); + try { + factory.get("wss://relay.example.com").emitError(new RuntimeException("x")); + assertTrue(errors.stream().anyMatch(e -> "x".equals(e.getMessage()))); + } finally { + handle.close(); + } + } + + /** + * Subscribing without an explicit error listener should use a safe default and not throw when + * errors occur. + */ + @Test + void testSubscribeWithoutErrorListenerUsesSafeDefault() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = Map.of("relay", "wss://relay.example.com"); + client.setRelays(relays); + + AutoCloseable handle = + client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-safe", m -> {}); + try { + // Emit an error; should be handled by safe default error consumer, not rethrown + factory.get("wss://relay.example.com").emitError(new RuntimeException("err-safe")); + assertTrue(true); + } finally { + handle.close(); + } + } + + /** + * Confirms that EOSE markers propagate to listeners as regular messages. + */ + @Test + void testEOSEMarkerReceived() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = Map.of("relay", "wss://relay.example.com"); + client.setRelays(relays); + + List received = new ArrayList<>(); + AutoCloseable handle = + client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-eose", received::add); + try { + factory.get("wss://relay.example.com").emit("[\"EOSE\",\"sub-eose\"]"); + assertTrue(received.stream().anyMatch(s -> s.contains("EOSE"))); + } finally { + handle.close(); + } + } + + /** + * Ensures cancellation closes underlying subscription and sends CLOSE frame. + */ + @Test + void testCancelSubscriptionSendsClose() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + FakeWebSocketClientFactory factory = new FakeWebSocketClientFactory(); + NostrSpringWebSocketClient client = + new NostrSpringWebSocketClient(sender, new DefaultNoteService(), factory); + + Map relays = Map.of("relay", "wss://relay.example.com"); + client.setRelays(relays); + + AutoCloseable handle = + client.subscribe(new Filters(new KindFilter<>(Kind.TEXT_NOTE)), "sub-close", s -> {}); + FakeWebSocketClient fake = factory.get("wss://relay.example.com"); + try { + handle.close(); + } finally { + // Verify a CLOSE message was sent (subscribe called with CLOSE frame) + assertTrue( + fake.getSentPayloads().stream().anyMatch(p -> p.contains("\"CLOSE\",\"sub-close\"")), + "Close frame should be sent for subscription id"); + } + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClient.java b/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClient.java new file mode 100644 index 00000000..2892631a --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClient.java @@ -0,0 +1,135 @@ +package nostr.api.integration.support; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; +import lombok.Getter; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import nostr.client.springwebsocket.WebSocketClientIF; +import nostr.event.BaseMessage; + +/** + * Minimal in‑memory WebSocket client used by integration tests to simulate relay behavior. + * + *

Records sent payloads and allows tests to emit inbound messages or errors to subscribed + * listeners. Intended for deterministic, fast, and offline test scenarios. + */ +@Slf4j +public class FakeWebSocketClient implements WebSocketClientIF { + + /** The relay URL this fake is bound to (for assertions/identification). */ + @Getter private final String relayUrl; + + private volatile boolean open = true; + + private final List sentPayloads = Collections.synchronizedList(new ArrayList<>()); + private final ConcurrentMap listeners = new ConcurrentHashMap<>(); + + /** + * Creates a fake client for the given relay URL. + * + * @param relayUrl relay endpoint identifier + */ + public FakeWebSocketClient(@NonNull String relayUrl) { + this.relayUrl = relayUrl; + } + + /** + * Encodes and forwards a message for {@link #send(String)}. + */ + @Override + public List send(T eventMessage) throws IOException { + return send(eventMessage.encode()); + } + + /** + * Appends the raw JSON to the internal log and returns an OK stub response. + */ + @Override + public List send(String json) throws IOException { + if (!open) { + throw new IOException("WebSocket session is closed for " + relayUrl); + } + sentPayloads.add(json); + // Return a simple response containing the relay URL for identification + return List.of("OK:" + relayUrl); + } + + /** + * Registers a listener and records the subscription REQ payload. + */ + @Override + public AutoCloseable subscribe( + String requestJson, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException { + Objects.requireNonNull(messageListener, "messageListener"); + Objects.requireNonNull(errorListener, "errorListener"); + if (!open) { + throw new IOException("WebSocket session is closed for " + relayUrl); + } + String id = UUID.randomUUID().toString(); + listeners.put(id, new Listener(messageListener, errorListener, closeListener)); + sentPayloads.add(requestJson); + return () -> listeners.remove(id); + } + + /** + * Closes the fake session and notifies close listeners once. + */ + @Override + public void close() throws IOException { + if (!open) return; + open = false; + // Notify close listeners once + for (Listener listener : listeners.values()) { + try { + if (listener.closeListener != null) listener.closeListener.run(); + } catch (Exception e) { + log.warn("Close listener threw on {}", relayUrl, e); + } + } + listeners.clear(); + } + + /** + * Returns a snapshot of all sent payloads. + */ + public List getSentPayloads() { + return List.copyOf(sentPayloads); + } + + /** + * Emits an inbound message to all registered listeners. + */ + public void emit(String payload) { + for (Listener listener : listeners.values()) { + try { + listener.messageListener.accept(payload); + } catch (Exception e) { + if (listener.errorListener != null) listener.errorListener.accept(e); + } + } + } + + /** + * Emits an inbound error to all registered error listeners. + */ + public void emitError(Throwable t) { + for (Listener listener : listeners.values()) { + if (listener.errorListener != null) listener.errorListener.accept(t); + } + } + + private record Listener( + Consumer messageListener, Consumer errorListener, Runnable closeListener) {} +} diff --git a/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClientFactory.java b/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClientFactory.java new file mode 100644 index 00000000..b0f17609 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/integration/support/FakeWebSocketClientFactory.java @@ -0,0 +1,42 @@ +package nostr.api.integration.support; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import lombok.NonNull; +import nostr.base.RelayUri; +import nostr.client.WebSocketClientFactory; +import nostr.client.springwebsocket.WebSocketClientIF; + +/** + * In-memory {@link WebSocketClientFactory} for tests. + * + *

Produces {@link FakeWebSocketClient} instances keyed by relay URI and caches them so tests + * can both inject behavior and later inspect what messages were sent. + */ +public class FakeWebSocketClientFactory implements WebSocketClientFactory { + + private final Map clients = new ConcurrentHashMap<>(); + + /** + * Returns a cached fake client for the given relay or creates a new one. + * + * @param relayUri target relay URI + * @return a {@link WebSocketClientIF} backed by {@link FakeWebSocketClient} + */ + @Override + public WebSocketClientIF create(@NonNull RelayUri relayUri) + throws ExecutionException, InterruptedException { + return clients.computeIfAbsent(relayUri.toString(), FakeWebSocketClient::new); + } + + /** + * Retrieves a previously created fake client by its relay URI. + * + * @param relayUri string form of the relay URI + * @return the fake client or {@code null} if none was created yet + */ + public FakeWebSocketClient get(String relayUri) { + return clients.get(relayUri); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/unit/Bolt11UtilTest.java b/nostr-java-api/src/test/java/nostr/api/unit/Bolt11UtilTest.java new file mode 100644 index 00000000..83b221f8 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/unit/Bolt11UtilTest.java @@ -0,0 +1,92 @@ +package nostr.api.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import nostr.api.nip57.Bolt11Util; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for Bolt11Util amount parsing. + */ +public class Bolt11UtilTest { + + @Test + // Parses nanoBTC amount (n) into msat. Example: 50n BTC → 5000 msat. + void parseNanoBtcToMsat() { + // 50n BTC = 50 * 10^-9 BTC → 50 * 10^2 sat → 5000 msat + long msat = Bolt11Util.parseMsat("lnbc50n1pxyz"); + assertEquals(5_000L, msat); + } + + @Test + // Parses picoBTC amount (p) into msat. Example: 2000p BTC → 200 msat. + void parsePicoBtcToMsat() { + // 2000p BTC = 2000 * 10^-12 BTC → 0.2 sat → 200 msat + long msat = Bolt11Util.parseMsat("lnbc2000p1pabc"); + assertEquals(200L, msat); + } + + @Test + // Invoice without amount returns -1 to indicate any-amount invoice. + void parseNoAmountInvoice() { + long msat = Bolt11Util.parseMsat("lnbc1pnoamount"); + assertEquals(-1L, msat); + } + + @Test + // Invalid HRP throws IllegalArgumentException. + void invalidInvoiceThrows() { + assertThrows(IllegalArgumentException.class, () -> Bolt11Util.parseMsat("notbolt11")); + } + + @Test + // Parses milliBTC (m) unit into msat. Example: 2m BTC → 200,000,000 msat. + void parseMilliBtcToMsat() { + long msat = Bolt11Util.parseMsat("lnbc2m1ptest"); + assertEquals(200_000_000L, msat); + } + + @Test + // Parses microBTC (u) unit into msat. Example: 25u BTC → 2,500,000 msat. + void parseMicroBtcToMsat() { + long msat = Bolt11Util.parseMsat("lntb25u1ptest"); + assertEquals(2_500_000L, msat); + } + + @Test + // Parses BTC with no unit. Example: 1 BTC → 100,000,000,000 msat. + void parseWholeBtcNoUnit() { + long msat = Bolt11Util.parseMsat("lnbc1psome"); + assertEquals(100_000_000_000L, msat); + } + + @Test + // Accepts uppercase invoice strings by normalizing to lowercase. + void parseUppercaseInvoice() { + long msat = Bolt11Util.parseMsat("LNBC50N1PUPPER"); + assertEquals(5_000L, msat); + } + + @Test + // Supports testnet network code (lntb...). + void parseTestnetNano() { + long msat = Bolt11Util.parseMsat("lntb50n1pxyz"); + assertEquals(5_000L, msat); + } + + @Test + // Supports regtest network code (lnbcrt...). + void parseRegtestNano() { + long msat = Bolt11Util.parseMsat("lnbcrt50n1pxyz"); + assertEquals(5_000L, msat); + } + + @Test + // Excessively large amounts should throw due to overflow protection. + void parseTooLargeThrows() { + // This crafts a huge value: 9999999999999999999m BTC -> will exceed Long.MAX_VALUE in msat + String huge = "lnbc9999999999999999999m1pbig"; + assertThrows(IllegalArgumentException.class, () -> Bolt11Util.parseMsat(huge)); + } +} diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP01MessagesTest.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP01MessagesTest.java new file mode 100644 index 00000000..f22b5221 --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP01MessagesTest.java @@ -0,0 +1,73 @@ +package nostr.api.unit; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import nostr.api.NIP01; +import nostr.base.Kind; +import nostr.event.filter.Filters; +import nostr.event.filter.KindFilter; +import nostr.event.impl.GenericEvent; +import nostr.event.message.CloseMessage; +import nostr.event.message.EoseMessage; +import nostr.event.message.EventMessage; +import nostr.event.message.NoticeMessage; +import nostr.event.message.ReqMessage; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** Unit tests for NIP-01 message creation and encoding. */ +public class NIP01MessagesTest { + + @Test + // EVENT message encodes with command and optional subscription id + void eventMessageEncodes() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + NIP01 nip01 = new NIP01(sender); + GenericEvent event = nip01.createTextNoteEvent("hi").sign().getEvent(); + + EventMessage msg = NIP01.createEventMessage(event, "sub-ev"); + String json = msg.encode(); + assertTrue(json.contains("\"EVENT\"")); + assertTrue(json.contains("\"sub-ev\"")); + } + + @Test + // REQ message encodes subscription id and filters + void reqMessageEncodes() throws Exception { + Filters filters = new Filters(new KindFilter<>(Kind.TEXT_NOTE)); + ReqMessage msg = NIP01.createReqMessage("sub-req", List.of(filters)); + String json = msg.encode(); + assertTrue(json.contains("\"REQ\"")); + assertTrue(json.contains("\"sub-req\"")); + assertTrue(json.contains("\"kinds\"")); + } + + @Test + // CLOSE message encodes subscription id + void closeMessageEncodes() throws Exception { + CloseMessage msg = NIP01.createCloseMessage("sub-close"); + String json = msg.encode(); + assertTrue(json.contains("\"CLOSE\"")); + assertTrue(json.contains("\"sub-close\"")); + } + + @Test + // EOSE message encodes subscription id + void eoseMessageEncodes() throws Exception { + EoseMessage msg = NIP01.createEoseMessage("sub-eose"); + String json = msg.encode(); + assertTrue(json.contains("\"EOSE\"")); + assertTrue(json.contains("\"sub-eose\"")); + } + + @Test + // NOTICE message encodes human readable message + void noticeMessageEncodes() throws Exception { + NoticeMessage msg = NIP01.createNoticeMessage("hello"); + String json = msg.encode(); + assertTrue(json.contains("\"NOTICE\"")); + assertTrue(json.contains("\"hello\"")); + } +} + diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP42Test.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP42Test.java index 25bdaa39..02e8094c 100644 --- a/nostr-java-api/src/test/java/nostr/api/unit/NIP42Test.java +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP42Test.java @@ -1,11 +1,18 @@ package nostr.api.unit; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import nostr.api.NIP42; +import nostr.base.Kind; import nostr.base.Relay; import nostr.event.BaseTag; +import nostr.event.impl.CanonicalAuthenticationEvent; +import nostr.event.impl.GenericEvent; +import nostr.event.message.CanonicalAuthenticationMessage; import nostr.event.tag.GenericTag; +import nostr.id.Identity; import org.junit.jupiter.api.Test; public class NIP42Test { @@ -21,4 +28,39 @@ public void testCreateTags() { assertEquals("challenge", cTag.getCode()); assertEquals("abc", ((GenericTag) cTag).getAttributes().get(0).value()); } + + @Test + // Build a canonical auth event and client AUTH message; verify kind and required tags. + public void testCanonicalAuthEventAndMessage() throws Exception { + Identity sender = Identity.generateRandomIdentity(); + Relay relay = new Relay("wss://relay.example.com"); + NIP42 nip42 = new NIP42(); + nip42.setSender(sender); + + GenericEvent ev = nip42.createCanonicalAuthenticationEvent("token-123", relay).sign().getEvent(); + + assertEquals(Kind.CLIENT_AUTH.getValue(), ev.getKind()); + assertTrue(ev.getTags().stream().anyMatch(t -> t.getCode().equals("relay"))); + assertTrue(ev.getTags().stream().anyMatch(t -> t.getCode().equals("challenge"))); + + CanonicalAuthenticationEvent authEvent = GenericEvent.convert(ev, CanonicalAuthenticationEvent.class); + assertDoesNotThrow(authEvent::validate); + + CanonicalAuthenticationMessage msg = NIP42.createClientAuthenticationMessage(authEvent); + String json = msg.encode(); + assertTrue(json.contains("\"AUTH\"")); + // Encoded AUTH message should embed the full event JSON including tags + assertTrue(json.contains("\"tags\"")); + assertTrue(json.contains("relay")); + assertTrue(json.contains("challenge")); + } + + @Test + // Relay AUTH message includes challenge attribute. + public void testRelayAuthMessage() throws Exception { + String json = NIP42.createRelayAuthenticationMessage("c-1").encode(); + assertTrue(json.contains("\"AUTH\"")); + assertTrue(json.contains("challenge")); + assertTrue(json.contains("c-1")); + } } diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP46Test.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP46Test.java index d7662fd8..1f4957f6 100644 --- a/nostr-java-api/src/test/java/nostr/api/unit/NIP46Test.java +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP46Test.java @@ -1,8 +1,6 @@ package nostr.api.unit; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import nostr.api.NIP46; import nostr.id.Identity; @@ -37,4 +35,53 @@ public void testCreateRequestEvent() { nip46.createRequestEvent(req, signer.getPublicKey()); assertNotNull(nip46.getEvent()); } + + @Test + // Request event should be kind NOSTR_CONNECT, include p-tag of signer, and have encrypted content. + public void testRequestEventCompliance() { + Identity app = Identity.generateRandomIdentity(); + Identity signer = Identity.generateRandomIdentity(); + NIP46 nip46 = new NIP46(app); + NIP46.Request req = new NIP46.Request("42", "get_public_key", null); + var event = nip46.createRequestEvent(req, signer.getPublicKey()).sign().getEvent(); + + assertEquals(nostr.base.Kind.NOSTR_CONNECT.getValue(), event.getKind()); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals("p")), "p-tag must be present"); + assertNotNull(event.getContent()); + assertFalse(event.getContent().isEmpty()); + } + + @Test + // Response event should also be kind NOSTR_CONNECT and include app p-tag. + public void testResponseEventCompliance() { + Identity signer = Identity.generateRandomIdentity(); + Identity app = Identity.generateRandomIdentity(); + NIP46 nip46 = new NIP46(signer); + NIP46.Response resp = new NIP46.Response("42", null, "ok"); + var event = nip46.createResponseEvent(resp, app.getPublicKey()).sign().getEvent(); + assertEquals(nostr.base.Kind.NOSTR_CONNECT.getValue(), event.getKind()); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals("p"))); + } + + @Test + // Multi-parameter request should serialize deterministically and decrypt to original payload. + public void testMultiParamRequestRoundTrip() { + Identity app = Identity.generateRandomIdentity(); + Identity signer = Identity.generateRandomIdentity(); + NIP46 nip46 = new NIP46(app); + + NIP46.Request req = new NIP46.Request("7", "sign_event", null); + req.addParam("kind=1"); + req.addParam("tag=p:abcd"); + + var ev = nip46.createRequestEvent(req, signer.getPublicKey()).sign().getEvent(); + assertEquals(nostr.base.Kind.NOSTR_CONNECT.getValue(), ev.getKind()); + + String decrypted = nostr.api.NIP44.decrypt(signer, ev, app.getPublicKey()); + NIP46.Request parsed = NIP46.Request.fromString(decrypted); + assertEquals("7", parsed.getId()); + assertEquals("sign_event", parsed.getMethod()); + assertTrue(parsed.getParams().contains("kind=1")); + assertTrue(parsed.getParams().contains("tag=p:abcd")); + } } diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP57ImplTest.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP57ImplTest.java index 6d231117..a44632fd 100644 --- a/nostr-java-api/src/test/java/nostr/api/unit/NIP57ImplTest.java +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP57ImplTest.java @@ -254,4 +254,110 @@ void testZapReceiptCreation() throws NostrException { .anyMatch(tag -> tag.getCode().equals("description")); assertTrue(hasDescription, "Zap receipt must contain description tag with zap request"); } + + @Test + // Validates that the zap receipt bolt11 amount matches the zap request amount. + void testZapAmountMatchesInvoiceAmount() throws NostrException { + ZapRequestParameters requestParams = + ZapRequestParameters.builder() + .amount(5_000L) // 5000 msat + .lnUrl("lnurl_amount_match") + .relay(new Relay("wss://relay.example.com")) + .content("amount match") + .recipientPubKey(zapRecipient.getPublicKey()) + .build(); + + GenericEvent zapRequest = nip57.createZapRequestEvent(requestParams).getEvent(); + + // Mock invoice that would encode 5000 msat (placeholder) + String bolt11Invoice = "lnbc50n1p..."; + String preimage = "00cafebabe"; + NIP57 receiptBuilder = new NIP57(zapRecipient); + GenericEvent receipt = + receiptBuilder + .createZapReceiptEvent(zapRequest, bolt11Invoice, preimage, sender.getPublicKey()) + .getEvent(); + + assertNotNull(receipt); + } + + @Test + // Verifies description_hash equals SHA-256 of the description JSON for the zap request. + void testZapDescriptionHash() throws Exception { + ZapRequestParameters requestParams = + ZapRequestParameters.builder() + .amount(1_000L) + .lnUrl("lnurl_desc_hash") + .relay(new Relay("wss://relay.example.com")) + .content("hash me") + .recipientPubKey(zapRecipient.getPublicKey()) + .build(); + + GenericEvent zapRequest = nip57.createZapRequestEvent(requestParams).getEvent(); + String bolt11 = "lnbc10n1p..."; + String preimage = "00112233"; + NIP57 receiptBuilder = new NIP57(zapRecipient); + GenericEvent receipt = + receiptBuilder + .createZapReceiptEvent(zapRequest, bolt11, preimage, sender.getPublicKey()) + .getEvent(); + + // Extract description and description_hash tags + var descriptionTagOpt = receipt.getTags().stream() + .filter(t -> t.getCode().equals("description")) + .findFirst(); + var descriptionHashTagOpt = receipt.getTags().stream() + .filter(t -> t.getCode().equals("description_hash")) + .findFirst(); + assertTrue(descriptionTagOpt.isPresent()); + assertTrue(descriptionHashTagOpt.isPresent()); + + String descEscaped = ((nostr.event.tag.GenericTag) descriptionTagOpt.get()) + .getAttributes().get(0).value().toString(); + + // Unescape and hash + String desc = nostr.util.NostrUtil.unEscapeJsonString(descEscaped); + String expectedHash = nostr.util.NostrUtil.bytesToHex(nostr.util.NostrUtil.sha256(desc.getBytes())); + String actualHash = ((nostr.event.tag.GenericTag) descriptionHashTagOpt.get()).getAttributes().get(0).value().toString(); + assertEquals(expectedHash, actualHash, "description_hash must equal SHA-256 of description JSON"); + } + + @Test + // Validates that creating a zap receipt with missing required fields fails fast. + void testInvalidZapReceiptMissingFields() throws NostrException { + ZapRequestParameters requestParams = + ZapRequestParameters.builder() + .amount(1_000L) + .lnUrl("lnurl_test_receipt") + .relay(new Relay("wss://relay.example.com")) + .content("zap") + .recipientPubKey(zapRecipient.getPublicKey()) + .build(); + + GenericEvent zapRequest = nip57.createZapRequestEvent(requestParams).getEvent(); + NIP57 receiptBuilder = new NIP57(zapRecipient); + + // Missing bolt11 + assertThrows( + NullPointerException.class, + () -> receiptBuilder.createZapReceiptEvent(zapRequest, null, "preimage", sender.getPublicKey())); + // Missing preimage + assertThrows( + NullPointerException.class, + () -> receiptBuilder.createZapReceiptEvent(zapRequest, "bolt11", null, sender.getPublicKey())); + } + + @Test + // Ensures a zap request without relays information is rejected. + void testZapRequestMissingRelaysThrows() { + // Build parameters without relaysTag or relays list + ZapRequestParameters.ZapRequestParametersBuilder builder = + ZapRequestParameters.builder() + .amount(123L) + .lnUrl("lnurl_no_relays") + .content("no relays") + .recipientPubKey(zapRecipient.getPublicKey()); + + assertThrows(IllegalStateException.class, () -> builder.build().determineRelaysTag()); + } } diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP65Test.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP65Test.java index 79d3f2a4..cf08d066 100644 --- a/nostr-java-api/src/test/java/nostr/api/unit/NIP65Test.java +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP65Test.java @@ -1,8 +1,10 @@ package nostr.api.unit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Map; import nostr.api.NIP65; import nostr.base.Marker; import nostr.base.Relay; @@ -20,5 +22,33 @@ public void testCreateRelayListMetadataEvent() { nip65.createRelayListMetadataEvent(List.of(relay), Marker.READ); GenericEvent event = nip65.getEvent(); assertEquals("r", event.getTags().get(0).getCode()); + assertTrue(event.getTags().get(0).toString().contains(Marker.READ.getValue())); + } + + @Test + public void testCreateRelayListMetadataEventMapVariant() { + Identity sender = Identity.generateRandomIdentity(); + NIP65 nip65 = new NIP65(sender); + Relay r1 = new Relay("wss://relay1"); + Relay r2 = new Relay("wss://relay2"); + nip65.createRelayListMetadataEvent(Map.of(r1, Marker.READ, r2, Marker.WRITE)); + GenericEvent event = nip65.getEvent(); + assertEquals(nostr.base.Kind.RELAY_LIST_METADATA.getValue(), event.getKind()); + assertTrue(event.getTags().stream().anyMatch(t -> t.toString().contains("relay1"))); + assertTrue(event.getTags().stream().anyMatch(t -> t.toString().contains(Marker.WRITE.getValue()))); + } + + @Test + public void testRelayTagOrderPreserved() { + Identity sender = Identity.generateRandomIdentity(); + NIP65 nip65 = new NIP65(sender); + Relay r1 = new Relay("wss://r1"); + Relay r2 = new Relay("wss://r2"); + nip65.createRelayListMetadataEvent(List.of(r1, r2)); + GenericEvent event = nip65.getEvent(); + String t0 = event.getTags().get(0).toString(); + String t1 = event.getTags().get(1).toString(); + assertTrue(t0.contains("wss://r1")); + assertTrue(t1.contains("wss://r2")); } } diff --git a/nostr-java-api/src/test/java/nostr/api/unit/NIP99Test.java b/nostr-java-api/src/test/java/nostr/api/unit/NIP99Test.java new file mode 100644 index 00000000..3fb36f3f --- /dev/null +++ b/nostr-java-api/src/test/java/nostr/api/unit/NIP99Test.java @@ -0,0 +1,88 @@ +package nostr.api.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import nostr.api.NIP99; +import nostr.base.Kind; +import nostr.config.Constants; +import nostr.event.BaseTag; +import nostr.event.entities.ClassifiedListing; +import nostr.event.impl.GenericEvent; +import nostr.event.tag.PriceTag; +import nostr.id.Identity; +import org.junit.jupiter.api.Test; + +/** Unit tests for NIP-99 classified listings (event building and required tags). */ +public class NIP99Test { + + @Test + // Builds a classified listing with title, summary, price and optional fields; verifies tags. + void createClassifiedListingEvent_withAllFields() throws MalformedURLException { + Identity sender = Identity.generateRandomIdentity(); + NIP99 nip99 = new NIP99(sender); + + PriceTag price = PriceTag.builder().number(new BigDecimal("19.99")).currency("USD").frequency("day").build(); + ClassifiedListing listing = + ClassifiedListing.builder("Desk", "Wooden desk", price) + .publishedAt(1700000000L) + .location("Seattle, WA") + .build(); + + BaseTag image = nostr.api.NIP23.createImageTag(new URL("https://example.com/image.jpg"), "800x600"); + List baseTags = List.of(image); + + GenericEvent event = + nip99.createClassifiedListingEvent(baseTags, "Solid oak.", listing).getEvent(); + + // Kind is classified listing + assertEquals(Kind.CLASSIFIED_LISTING.getValue(), event.getKind()); + + // Required NIP-23/NIP-99 tags present + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.TITLE_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.SUMMARY_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.PRICE_CODE))); + + // Optional: published_at, location, image + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.PUBLISHED_AT_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.LOCATION_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.IMAGE_CODE))); + + // Price content integrity + PriceTag priceTag = (PriceTag) event.getTags().stream() + .filter(t -> t instanceof PriceTag) + .findFirst() + .orElseThrow(); + assertEquals(new BigDecimal("19.99"), priceTag.getNumber()); + assertEquals("USD", priceTag.getCurrency()); + assertEquals("day", priceTag.getFrequency()); + } + + @Test + // Builds a minimal classified listing with title, summary, and price; verifies required tags only. + void createClassifiedListingEvent_minimal() { + Identity sender = Identity.generateRandomIdentity(); + NIP99 nip99 = new NIP99(sender); + + PriceTag price = PriceTag.builder().number(new BigDecimal("100")).currency("EUR").build(); + ClassifiedListing listing = ClassifiedListing.builder("Bike", "Used bike", price).build(); + + GenericEvent event = + nip99.createClassifiedListingEvent(List.of(), "Great condition", listing).getEvent(); + + // Kind + assertEquals(Kind.CLASSIFIED_LISTING.getValue(), event.getKind()); + // Required tags present + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.TITLE_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.SUMMARY_CODE))); + assertTrue(event.getTags().stream().anyMatch(t -> t.getCode().equals(Constants.Tag.PRICE_CODE))); + // Optional tags absent + assertTrue(event.getTags().stream().noneMatch(t -> t.getCode().equals(Constants.Tag.PUBLISHED_AT_CODE))); + assertTrue(event.getTags().stream().noneMatch(t -> t.getCode().equals(Constants.Tag.LOCATION_CODE))); + } +} + diff --git a/nostr-java-base/pom.xml b/nostr-java-base/pom.xml index 6538d790..1b28090f 100644 --- a/nostr-java-base/pom.xml +++ b/nostr-java-base/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index c0d713ec..179a6e55 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md b/nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md new file mode 100644 index 00000000..16c507ba --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/README.md @@ -0,0 +1,16 @@ +# Client Module Tests (springwebsocket) + +This package contains tests for the Spring-based WebSocket client. + +## What’s covered + +- `SpringWebSocketClientTest` + - Retry behavior for `send(String)` with recoveries and final failure + - Retry behavior for `subscribe(...)` (message overload and raw String overload) +- `StandardWebSocketClientTimeoutTest` + - Timeout path returns an empty list and closes session + +## Notes + +- The tests wire a test WebSocketClientIF into `SpringWebSocketClient` using Spring’s `@Configuration` to simulate retries and failures deterministically. +- Keep callbacks (`messageListener`, `errorListener`, `closeListener`) short and non-blocking in production; tests use simple counters to assert behavior. diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientSubscribeTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientSubscribeTest.java new file mode 100644 index 00000000..f0010d9e --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientSubscribeTest.java @@ -0,0 +1,101 @@ +package nostr.client.springwebsocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import lombok.Getter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig( + classes = { + RetryConfig.class, + SpringWebSocketClient.class, + SpringWebSocketClientSubscribeTest.TestConfig.class + }) +@TestPropertySource(properties = "nostr.relay.uri=wss://test") +class SpringWebSocketClientSubscribeTest { + + @Configuration + static class TestConfig { + @Bean + EmitterWebSocketClient webSocketClientIF() { + return new EmitterWebSocketClient(); + } + } + + static class EmitterWebSocketClient implements WebSocketClientIF { + @Getter private String lastJson; + private Consumer messageListener; + private Consumer errorListener; + private Runnable closeListener; + + @Override + public java.util.List send(T eventMessage) + throws IOException { + return send(eventMessage.encode()); + } + + @Override + public java.util.List send(String json) throws IOException { + lastJson = json; + return java.util.List.of(); + } + + @Override + public AutoCloseable subscribe( + String requestJson, + Consumer messageListener, + Consumer errorListener, + Runnable closeListener) + throws IOException { + this.lastJson = requestJson; + this.messageListener = messageListener; + this.errorListener = errorListener; + this.closeListener = closeListener; + return () -> { + if (this.closeListener != null) this.closeListener.run(); + }; + } + + @Override + public void close() {} + + void emit(String payload) { if (messageListener != null) messageListener.accept(payload); } + void emitError(Throwable t) { if (errorListener != null) errorListener.accept(t); } + } + + @Autowired private SpringWebSocketClient client; + @Autowired private EmitterWebSocketClient webSocketClientIF; + + @Test + void subscribeReceivesMessagesAndErrorAndClose() throws Exception { + AtomicInteger messages = new AtomicInteger(); + AtomicInteger errors = new AtomicInteger(); + AtomicInteger closes = new AtomicInteger(); + + AutoCloseable handle = + client.subscribe( + new nostr.event.message.ReqMessage("sub-1", new nostr.event.filter.Filters[] {}), + payload -> messages.incrementAndGet(), + t -> errors.incrementAndGet(), + closes::incrementAndGet()); + + webSocketClientIF.emit("EVENT"); + webSocketClientIF.emitError(new IOException("boom")); + handle.close(); + + assertEquals(1, messages.get()); + assertEquals(1, errors.get()); + assertEquals(1, closes.get()); + assertTrue(webSocketClientIF.getLastJson().contains("\"REQ\"")); + } +} + diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java index 3d2db842..38e26b44 100644 --- a/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/SpringWebSocketClientTest.java @@ -37,6 +37,8 @@ TestWebSocketClient webSocketClientIF() { static class TestWebSocketClient implements WebSocketClientIF { @Getter @Setter private int attempts; @Setter private int failuresBeforeSuccess; + @Getter @Setter private int subAttempts; + @Setter private int subFailuresBeforeSuccess; @Override public List send(T eventMessage) throws IOException { @@ -59,6 +61,10 @@ public AutoCloseable subscribe( Consumer errorListener, Runnable closeListener) throws IOException { + subAttempts++; + if (subAttempts <= subFailuresBeforeSuccess) { + throw new IOException("sub-fail"); + } return () -> {}; } @@ -92,4 +98,47 @@ void recoverAfterMaxAttempts() { assertThrows(IOException.class, () -> client.send("payload")); assertEquals(3, webSocketClientIF.getAttempts()); } + + // Ensures retryable subscribe eventually succeeds after configured transient failures. + @Test + void subscribeRetriesUntilSuccess() throws Exception { + webSocketClientIF.setSubFailuresBeforeSuccess(2); + AutoCloseable h = + client.subscribe( + new nostr.event.message.ReqMessage("sub-1", new nostr.event.filter.Filters[] {}), + s -> {}, + t -> {}, + () -> {}); + h.close(); + assertEquals(3, webSocketClientIF.getSubAttempts()); + } + + // Ensures subscribe surfaces final IOException after exhausting retries. + @Test + void subscribeRecoverAfterMaxAttempts() { + webSocketClientIF.setSubFailuresBeforeSuccess(5); + assertThrows( + IOException.class, + () -> + client.subscribe( + new nostr.event.message.ReqMessage("sub-2", new nostr.event.filter.Filters[] {}), + s -> {}, + t -> {}, + () -> {})); + assertEquals(3, webSocketClientIF.getSubAttempts()); + } + + // Ensures retry also applies to the raw String subscribe overload. + @Test + void subscribeRawRetriesUntilSuccess() throws Exception { + webSocketClientIF.setSubFailuresBeforeSuccess(1); + AutoCloseable h = + client.subscribe( + "[\"REQ\",\"sub-raw\",{}]", + s -> {}, + t -> {}, + () -> {}); + h.close(); + assertEquals(2, webSocketClientIF.getSubAttempts()); + } } diff --git a/nostr-java-crypto/pom.xml b/nostr-java-crypto/pom.xml index 6df61511..cb5e4153 100644 --- a/nostr-java-crypto/pom.xml +++ b/nostr-java-crypto/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/bech32/Bech32Test.java b/nostr-java-crypto/src/test/java/nostr/crypto/bech32/Bech32Test.java new file mode 100644 index 00000000..5478f5b1 --- /dev/null +++ b/nostr-java-crypto/src/test/java/nostr/crypto/bech32/Bech32Test.java @@ -0,0 +1,76 @@ +package nostr.crypto.bech32; + +import static org.junit.jupiter.api.Assertions.*; + +import nostr.crypto.bech32.Bech32.Bech32Data; +import nostr.crypto.bech32.Bech32.Encoding; +import org.junit.jupiter.api.Test; + +/** Tests for Bech32 encode/decode and NIP-19 helpers. */ +public class Bech32Test { + + private static final String HEX64 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + private static final String NPUB_FOR_HEX64 = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + + @Test + void toFromBech32RoundtripNpub() throws Exception { + String npub = Bech32.toBech32(Bech32Prefix.NPUB, HEX64); + assertTrue(npub.startsWith("npub")); + String hex = Bech32.fromBech32(npub); + assertEquals(HEX64, hex); + } + + @Test + void knownVectorNpub() throws Exception { + // As documented in Bech32 Javadoc + String npub = Bech32.toBech32(Bech32Prefix.NPUB, HEX64); + assertEquals(NPUB_FOR_HEX64, npub); + assertEquals(HEX64, Bech32.fromBech32(NPUB_FOR_HEX64)); + } + + @Test + void lowLevelEncodeDecode() throws Exception { + byte[] fiveBit = new byte[] {0,1,2,3,4,5,6,7,8,9}; + String s = Bech32.encode(Encoding.BECH32, "hrp", fiveBit); + Bech32Data d = Bech32.decode(s); + assertEquals("hrp", d.hrp); + assertEquals(Encoding.BECH32, d.encoding); + assertArrayEquals(fiveBit, d.data); + } + + @Test + void decodeRejectsInvalidCharsAndChecksum() { + assertThrows(Exception.class, () -> Bech32.decode("tooshort")); + assertThrows(Exception.class, () -> Bech32.decode("HRP1INV@LID")); + // wrong checksum + assertThrows(Exception.class, () -> Bech32.decode("hrp1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")); + } + + @Test + void bech32mEncodeDecode() throws Exception { + byte[] fiveBit = new byte[] {1,1,2,3,5,8,13}; + String s = Bech32.encode(Encoding.BECH32M, "nprof", fiveBit); + Bech32Data d = Bech32.decode(s); + assertEquals(Encoding.BECH32M, d.encoding); + assertEquals("nprof", d.hrp); + assertArrayEquals(fiveBit, d.data); + } + + @Test + void toBech32ForOtherPrefixes() { + String nsec = Bech32.toBech32(Bech32Prefix.NSEC, HEX64); + assertTrue(nsec.startsWith("nsec")); + String note = Bech32.toBech32(Bech32Prefix.NOTE, HEX64); + assertTrue(note.startsWith("note")); + } + + @Test + void fromBech32RejectsMalformed() { + // Missing separator + assertThrows(Exception.class, () -> Bech32.fromBech32("npub")); + // Invalid character + assertThrows(Exception.class, () -> Bech32.fromBech32("npub1inv@lid")); + // Short data part + assertThrows(Exception.class, () -> Bech32.fromBech32("npub1qqqq")); + } +} diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/schnorr/SchnorrTest.java b/nostr-java-crypto/src/test/java/nostr/crypto/schnorr/SchnorrTest.java new file mode 100644 index 00000000..07e7c777 --- /dev/null +++ b/nostr-java-crypto/src/test/java/nostr/crypto/schnorr/SchnorrTest.java @@ -0,0 +1,54 @@ +package nostr.crypto.schnorr; + +import static org.junit.jupiter.api.Assertions.*; + +import java.security.NoSuchAlgorithmException; +import nostr.util.NostrUtil; +import org.junit.jupiter.api.Test; + +/** Tests for Schnorr signing and verification helpers. */ +public class SchnorrTest { + + @Test + void signVerifyRoundtrip() throws Exception { + byte[] priv = Schnorr.generatePrivateKey(); + byte[] pub = Schnorr.genPubKey(priv); + byte[] msg = NostrUtil.createRandomByteArray(32); + byte[] aux = NostrUtil.createRandomByteArray(32); + + byte[] sig = Schnorr.sign(msg, priv, aux); + assertNotNull(sig); + assertEquals(64, sig.length); + assertTrue(Schnorr.verify(msg, pub, sig)); + } + + @Test + void verifyFailsForDifferentMessage() throws Exception { + byte[] priv = Schnorr.generatePrivateKey(); + byte[] pub = Schnorr.genPubKey(priv); + byte[] msg1 = NostrUtil.createRandomByteArray(32); + byte[] msg2 = NostrUtil.createRandomByteArray(32); + byte[] aux = NostrUtil.createRandomByteArray(32); + byte[] sig = Schnorr.sign(msg1, priv, aux); + assertFalse(Schnorr.verify(msg2, pub, sig)); + } + + @Test + void genPubKeyRejectsOutOfRangeKey() { + byte[] zeros = new byte[32]; + assertThrows(SchnorrException.class, () -> Schnorr.genPubKey(zeros)); + } + + @Test + void verifyRejectsInvalidLengths() throws Exception { + byte[] priv = Schnorr.generatePrivateKey(); + byte[] pub = Schnorr.genPubKey(priv); + byte[] msg = NostrUtil.createRandomByteArray(32); + byte[] sig = Schnorr.sign(msg, priv, NostrUtil.createRandomByteArray(32)); + + assertThrows(SchnorrException.class, () -> Schnorr.verify(new byte[31], pub, sig)); + assertThrows(SchnorrException.class, () -> Schnorr.verify(msg, new byte[31], sig)); + assertThrows(SchnorrException.class, () -> Schnorr.verify(msg, pub, new byte[63])); + } +} + diff --git a/nostr-java-encryption/pom.xml b/nostr-java-encryption/pom.xml index 85e14630..4db334ce 100644 --- a/nostr-java-encryption/pom.xml +++ b/nostr-java-encryption/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index eaa39a30..afb594c4 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-event/src/test/java/nostr/event/json/EventJsonMapperTest.java b/nostr-java-event/src/test/java/nostr/event/json/EventJsonMapperTest.java new file mode 100644 index 00000000..e9ac2e6e --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/json/EventJsonMapperTest.java @@ -0,0 +1,32 @@ +package nostr.event.json; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** Tests for EventJsonMapper contract. */ +public class EventJsonMapperTest { + + @Test + void getMapperReturnsSingleton() { + ObjectMapper m1 = EventJsonMapper.getMapper(); + ObjectMapper m2 = EventJsonMapper.getMapper(); + assertSame(m1, m2); + } + + @Test + void constructorIsInaccessible() { + assertThrows(UnsupportedOperationException.class, () -> { + var c = EventJsonMapper.class.getDeclaredConstructors()[0]; + c.setAccessible(true); + try { c.newInstance(); } catch (ReflectiveOperationException e) { + // unwrap + Throwable cause = e.getCause(); + if (cause instanceof UnsupportedOperationException uoe) throw uoe; + throw new RuntimeException(e); + } + }); + } +} + diff --git a/nostr-java-event/src/test/java/nostr/event/serializer/EventSerializerTest.java b/nostr-java-event/src/test/java/nostr/event/serializer/EventSerializerTest.java new file mode 100644 index 00000000..9a887fa1 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/serializer/EventSerializerTest.java @@ -0,0 +1,56 @@ +package nostr.event.serializer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import nostr.base.Kind; +import nostr.base.PublicKey; +import nostr.event.BaseTag; +import org.junit.jupiter.api.Test; + +/** Tests for EventSerializer utility methods. */ +public class EventSerializerTest { + + private static final String HEX64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @Test + void serializeAndComputeIdStable() throws Exception { + PublicKey pk = new PublicKey(HEX64); + long ts = 1700000000L; + String json = EventSerializer.serialize(pk, ts, Kind.TEXT_NOTE.getValue(), List.of(), "hello"); + assertTrue(json.startsWith("[")); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + String id = EventSerializer.computeEventId(bytes); + + // compute again should match + String id2 = EventSerializer.serializeAndComputeId(pk, ts, Kind.TEXT_NOTE.getValue(), List.of(), "hello"); + assertEquals(id, id2); + } + + @Test + void serializeThrowsForInvalidJsonTag() { + PublicKey pk = new PublicKey(HEX64); + // BaseTag.create with invalid params still serializes as generic tag; no exception expected + assertDoesNotThrow(() -> EventSerializer.serialize(pk, 1700000000L, Kind.TEXT_NOTE.getValue(), List.of(BaseTag.create("x")), "")); + } + + @Test + void computeEventIdThrowsForInvalidAlgorithmIsWrapped() { + // We cannot force NoSuchAlgorithmException easily without changing code; ensure basic path works + PublicKey pk = new PublicKey(HEX64); + assertDoesNotThrow(() -> EventSerializer.serializeAndComputeId(pk, null, Kind.TEXT_NOTE.getValue(), List.of(), "")); + } + + @Test + void serializeIncludesTagsArray() throws Exception { + PublicKey pk = new PublicKey(HEX64); + long ts = 1700000001L; + BaseTag e = BaseTag.create("e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + String json = EventSerializer.serialize(pk, ts, Kind.TEXT_NOTE.getValue(), List.of(e), ""); + assertTrue(json.contains("\"e\"")); + assertTrue(json.contains("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + // ensure tag array wrapper present + assertTrue(json.contains("[[")); + } +} diff --git a/nostr-java-event/src/test/java/nostr/event/support/GenericEventSupportTest.java b/nostr-java-event/src/test/java/nostr/event/support/GenericEventSupportTest.java new file mode 100644 index 00000000..831c7ee6 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/support/GenericEventSupportTest.java @@ -0,0 +1,69 @@ +package nostr.event.support; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import nostr.base.Kind; +import nostr.base.PublicKey; +import nostr.base.Signature; +import nostr.event.impl.GenericEvent; +import nostr.event.json.EventJsonMapper; +import nostr.util.NostrUtil; +import org.junit.jupiter.api.Test; + +/** Tests for GenericEventSerializer, Updater and Validator utility classes. */ +public class GenericEventSupportTest { + + private static final String HEX64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String HEX128 = HEX64 + HEX64; + + private GenericEvent newEvent() { + return GenericEvent.builder() + .pubKey(new PublicKey(HEX64)) + .kind(Kind.TEXT_NOTE) + .content("hello") + .build(); + } + + @Test + void serializerProducesCanonicalArray() throws Exception { + GenericEvent event = newEvent(); + String json = GenericEventSerializer.serialize(event); + // Expect leading 0, pubkey, created_at (may be null), kind, tags array, content string + assertTrue(json.startsWith("[")); + assertTrue(json.contains("\"" + event.getPubKey().toString() + "\"")); + assertTrue(json.contains("\"hello\"")); + } + + @Test + void updaterComputesIdAndSerializedCache() { + GenericEvent event = newEvent(); + GenericEventUpdater.refresh(event); + assertNotNull(event.getId()); + assertNotNull(event.getSerializedEventCache()); + // Recompute hash from serializer and compare + String serialized = new String(event.getSerializedEventCache(), StandardCharsets.UTF_8); + String expected = NostrUtil.bytesToHex(NostrUtil.sha256(serialized.getBytes(StandardCharsets.UTF_8))); + assertEquals(expected, event.getId()); + } + + @Test + void validatorAcceptsWellFormedEvent() throws Exception { + GenericEvent event = newEvent(); + // set required id and signature fields (hex format only) + GenericEventUpdater.refresh(event); + event.setSignature(Signature.fromString(HEX128)); + // serialize to produce id + event.setId(NostrUtil.bytesToHex(NostrUtil.sha256(GenericEventSerializer.serialize(event).getBytes(StandardCharsets.UTF_8)))); + assertDoesNotThrow(() -> GenericEventValidator.validate(event)); + } + + @Test + void validatorRejectsInvalidFields() { + GenericEvent event = newEvent(); + // Missing id/signature + assertThrows(AssertionError.class, () -> GenericEventValidator.validate(event)); + } +} + diff --git a/nostr-java-event/src/test/java/nostr/event/util/EventTypeCheckerTest.java b/nostr-java-event/src/test/java/nostr/event/util/EventTypeCheckerTest.java new file mode 100644 index 00000000..50dd4875 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/util/EventTypeCheckerTest.java @@ -0,0 +1,35 @@ +package nostr.event.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** Tests for EventTypeChecker ranges and naming. */ +public class EventTypeCheckerTest { + + @Test + void replacesEphemeralAddressableRegular() { + assertTrue(EventTypeChecker.isReplaceable(10000)); + assertTrue(EventTypeChecker.isEphemeral(20000)); + assertTrue(EventTypeChecker.isAddressable(30000)); + assertTrue(EventTypeChecker.isRegular(1)); + assertEquals("replaceable", EventTypeChecker.getTypeName(10001)); + assertEquals("ephemeral", EventTypeChecker.getTypeName(20001)); + assertEquals("addressable", EventTypeChecker.getTypeName(30001)); + assertEquals("regular", EventTypeChecker.getTypeName(40000)); + } + + @Test + void utilityClassConstructorThrows() { + assertThrows(UnsupportedOperationException.class, () -> { + var c = EventTypeChecker.class.getDeclaredConstructors()[0]; + c.setAccessible(true); + try { c.newInstance(); } catch (ReflectiveOperationException e) { + Throwable cause = e.getCause(); + if (cause instanceof UnsupportedOperationException uoe) throw uoe; + throw new RuntimeException(e); + } + }); + } +} + diff --git a/nostr-java-examples/pom.xml b/nostr-java-examples/pom.xml index 8ba73201..aca629ce 100644 --- a/nostr-java-examples/pom.xml +++ b/nostr-java-examples/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-id/pom.xml b/nostr-java-id/pom.xml index a3bd8919..3614ce8c 100644 --- a/nostr-java-id/pom.xml +++ b/nostr-java-id/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/nostr-java-util/pom.xml b/nostr-java-util/pom.xml index 02aeacc1..46753c24 100644 --- a/nostr-java-util/pom.xml +++ b/nostr-java-util/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT ../pom.xml diff --git a/pom.xml b/pom.xml index 1281048a..c6811016 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 0.6.4 + 0.6.5-SNAPSHOT pom ${project.artifactId} @@ -76,7 +76,7 @@ 1.1.1 - 0.6.4 + 0.6.5-SNAPSHOT 0.9.0 @@ -328,4 +328,48 @@ + + + + + no-docker + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + + **/nostr/api/integration/** + + + true + subclass + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven.failsafe.plugin.version} + + + + **/nostr/api/integration/** + + + true + subclass + + + + + + +