diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 489e604740..770cd13288 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -17,6 +17,10 @@ sourceSets { kotlin.srcDir("src/main/kotlin") resources.srcDir("src/main/resources") } + test { + kotlin.srcDir("src/test/kotlin") + resources.srcDir("src/test/resources") + } } dependencies { @@ -32,6 +36,11 @@ dependencies { implementation(libs.okhttpCoroutines) implementation(libs.jackson.module.kotlin) implementation(libs.slf4j.nop) + + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + // The JVM secp256k1 JNI binding so tests can exercise real signing/verification. + testImplementation(libs.secp256k1.kmp.jni.jvm) } // amy is headless. It compiles against zero Compose UI (the Compose deps are diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt index 69db556fd6..0a0b4947b5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt @@ -24,8 +24,12 @@ package com.vitorpamplona.amethyst.cli * Minimal argv parser. Splits flags (--key value or --key=value) from positional args. * Boolean flags are those whose next token starts with "--" or is absent. * - * The parser is intentionally tiny — this CLI is driven by shell scripts, not humans, - * so we don't need subcommand groups, short flags, or help text generation. + * A literal `--` token ends flag parsing: every later token is positional even if + * it starts with `--` (the same escape hatch getopt-style tools offer, so + * `amy notes post -- "--good morning"` posts the literal text). + * + * Every accessor records the flag names a command supports; [rejectUnknown] then + * turns any leftover `--typo` into a `bad_args` failure instead of a silent no-op. */ class Args( argv: Array, @@ -34,14 +38,21 @@ class Args( val booleans: Set val positional: List + /** Flag names a command declared support for by reading them. */ + private val consumed = mutableSetOf() + init { val f = mutableMapOf() val b = mutableSetOf() val p = mutableListOf() var i = 0 + var flagsEnded = false while (i < argv.size) { val a = argv[i] - if (a.startsWith("--")) { + if (!flagsEnded && a == "--") { + flagsEnded = true + i++ + } else if (!flagsEnded && a.startsWith("--")) { val eq = a.indexOf('=') if (eq >= 0) { f[a.substring(2, eq)] = a.substring(eq + 1) @@ -67,37 +78,72 @@ class Args( positional = p } + /** True when the caller asked for help (`--help`, or `-h` slipping in as a positional). */ + val help: Boolean get() = "help" in booleans || positional.firstOrNull() == "-h" + fun flag( name: String, default: String? = null, - ): String? = flags[name] ?: default + ): String? { + consumed.add(name) + return flags[name] ?: default + } fun intFlag( name: String, default: Int, - ): Int = flags[name]?.toIntOrNull() ?: default + ): Int { + consumed.add(name) + val raw = flags[name] ?: return default + return raw.toIntOrNull() + ?: throw IllegalArgumentException("--$name expects a number, got '$raw'") + } fun longFlag( name: String, default: Long, - ): Long = flags[name]?.toLongOrNull() ?: default + ): Long { + consumed.add(name) + val raw = flags[name] ?: return default + return raw.toLongOrNull() + ?: throw IllegalArgumentException("--$name expects a number, got '$raw'") + } - fun requireFlag(name: String): String = - flags[name] ?: run { - System.err.println("missing required flag: --$name") - throw IllegalArgumentException("missing flag $name") + fun requireFlag(name: String): String { + consumed.add(name) + return flags[name] + ?: throw IllegalArgumentException("missing required flag: --$name") + } + + fun bool(name: String): Boolean { + consumed.add(name) + return name in booleans + } + + /** + * Fail with `bad_args` (exit 2) when argv carried a flag no accessor asked + * about — the difference between `--limt 5` silently no-oping and the user + * learning about the typo. Call after every supported flag has been read + * (a conditional read still counts: `flag()`/`bool()` record the name even + * when the flag is absent). Commands that forward arbitrary flags simply + * don't call this. + */ + fun rejectUnknown(vararg alsoAllowed: String) { + val known = consumed + alsoAllowed + setOf("help") + val unknown = (flags.keys + booleans).filterNot { it in known } + if (unknown.isNotEmpty()) { + throw IllegalArgumentException( + "unknown flag${if (unknown.size > 1) "s" else ""}: ${unknown.joinToString(", ") { "--$it" }}", + ) } - - fun bool(name: String): Boolean = name in booleans + } fun positional( index: Int, name: String, ): String = - positional.getOrNull(index) ?: run { - System.err.println("missing positional arg: $name (index $index)") - throw IllegalArgumentException("missing positional $name") - } + positional.getOrNull(index) + ?: throw IllegalArgumentException("missing positional arg: $name") fun positionalOrNull(index: Int): String? = positional.getOrNull(index) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5aaba4ff9f..1cb8cbca48 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -106,6 +106,16 @@ import kotlin.system.exitProcess * shape is not. Diagnostic logs always go to stderr. */ fun main(argv: Array) { + exitProcess(runCli(argv)) +} + +/** + * Everything [main] does except the process exit — the seam the JVM test + * suite drives (`exitProcess` would kill the test JVM). Note the shared + * mutable bits ([Output.mode], [Log.minLevel]) persist across calls; tests + * reset them between invocations. + */ +fun runCli(argv: Array): Int { // Force AWT headless before any class load that might touch ImageIO, // Toolkit, or Graphics2D (image upload pulls in BufferedImage via // commons MediaMetadataReader / ImageReencoder). The Gradle launcher @@ -137,7 +147,7 @@ fun main(argv: Array) { Output.error("runtime", "${e::class.simpleName}: ${e.message}") 1 } - exitProcess(code) + return code } class AwaitTimeout( @@ -181,7 +191,8 @@ private suspend fun dispatch(argv: Array): Int { i += consumed.tokensConsumed } if (filteredArgs.isEmpty()) { - printUsage() + Output.error("bad_args", "no subcommand given") + printVerbList() return 2 } @@ -305,13 +316,37 @@ private suspend fun dispatch(argv: Array): Int { } "concord" -> ConcordCommands.dispatch(dataDir, tail) else -> { - System.err.println("unknown subcommand: $head") - printUsage() + Output.error("bad_args", "unknown subcommand: $head") + printVerbList() 2 } } } +/** + * The one-screen alternative to the full usage dump: shown on an unknown or + * missing subcommand so the error stays readable. `amy --help` still prints + * the full reference. + */ +private fun printVerbList() { + System.err.println( + """ + | + |Commands (see `amy --help` for the full reference, `amy --help` for one group): + | identity: init create login logoff whoami use status + | primitives: decode encode verify key filter nip kind pow namecoin + | events: event publish fetch subscribe count sync encrypt decrypt gift + | social: notes profile follow unfollow search zap dm outbox + | groups: marmot relaygroup concord geochat + | relays: relay admin serve store + | trust: graperank fof + | media/sites: blossom nsite napplet podcast podcast20 git + | payments: cashu offer debit + | signing: bunker + """.trimMargin(), + ) +} + private suspend fun marmotDispatch( dataDir: DataDir, tail: Array, @@ -680,15 +715,15 @@ private fun printUsage() { |CLINK Offers: | offer info NOFFER decode a noffer1… pointer (local, no network) | offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a - | [--timeout MS] fresh BOLT11 (amount required for spontaneous + | [--timeout SECS] fresh BOLT11 (amount required for spontaneous | offers; defaults to the pointer's fixed price) | |CLINK Debits: | debit info NDEBIT decode an ndebit1… pointer (local, no network) | debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the - | [--timeout MS] invoice; prints the preimage or a GFY error + | [--timeout SECS] invoice; prints the preimage or a GFY error | debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency - | [--frequency day|week|month] [--timeout MS] for a one-time budget + | [--frequency day|week|month] [--timeout SECS] for a one-time budget | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index d24b83a3dc..618ad4796f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -60,6 +60,12 @@ object Output { } } + /** + * Report a failure and return the exit code the process should end with. + * The code string picks the exit code — `bad_args` → 2, `timeout` → 124, + * everything else → 1 — so `return Output.error(…)` always honours the + * documented exit-code contract without per-site bookkeeping. + */ fun error( code: String, detail: String? = null, @@ -83,7 +89,11 @@ object Output { System.err.println(base + suffix) } } - return 1 + return when (code) { + "bad_args" -> 2 + "timeout" -> 124 + else -> 1 + } } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt index 9a81788869..fa638901a6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -50,15 +51,36 @@ object RawEventSupport { } } - /** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */ + /** Parse a `--relay a,b,c` flag into normalized relay URLs; an un-normalizable entry is a `bad_args` failure. */ fun relayFlag(args: Args): Set = args .flag("relay") ?.split(',') - ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } - ?.toSet() + ?.map { raw -> + RelayUrlNormalizer.normalizeOrNull(raw.trim()) + ?: throw IllegalArgumentException("invalid relay url: ${raw.trim()}") + }?.toSet() .orEmpty() + /** + * The exit decision after a publish: `null` when at least one relay (or + * no relay at all — a deliberately local-only build) accepted the event, + * or a non-zero exit code after reporting `rejected` when every targeted + * relay refused it. Callers use `publishGuard(ack, event.id)?.let { return it }` + * so a total rejection stops a `set -e` script instead of exiting 0. + */ + fun publishGuard( + ack: Map, + eventId: String, + ): Int? { + if (ack.isEmpty() || ack.any { it.value }) return null + return Output.error( + "rejected", + "no relay accepted event $eventId", + extra = mapOf("event_id" to eventId, "rejected_by" to ack.keys.map { it.url }), + ) + } + /** * Resolve where to publish: the explicit `--relay` set when given, * otherwise the account's NIP-65 outbox. Empty only when neither is @@ -91,27 +113,38 @@ object RawEventSupport { * --search TEXT NIP-50 * * Author/id decoding is local (no NIP-05 round-trip) — pass hex or a - * bech32 entity. Unparseable entries are dropped. + * bech32 entity. An unparseable entry is a `bad_args` failure: silently + * dropping it would run the query with a *weaker* filter than the user + * asked for and return silently-wrong results. */ fun buildFilter(args: Args): Filter { val kinds = args .flag("kind") ?.split(',') - ?.mapNotNull { it.trim().toIntOrNull() } - ?.takeIf { it.isNotEmpty() } + ?.map { raw -> + raw.trim().toIntOrNull() + ?: throw IllegalArgumentException("--kind expects a number, got '${raw.trim()}'") + }?.takeIf { it.isNotEmpty() } val authors = args .flag("author") ?.split(',') - ?.mapNotNull { decodePublicKeyAsHexOrNull(it.trim()) } - ?.takeIf { it.isNotEmpty() } + ?.map { raw -> + decodePublicKeyAsHexOrNull(raw.trim()) + ?: throw IllegalArgumentException( + "--author expects npub/nprofile/64-hex, got '${raw.trim()}' " + + "(NIP-05 names need a network round-trip — resolve first with `amy profile show`)", + ) + }?.takeIf { it.isNotEmpty() } val ids = args .flag("id") ?.split(',') - ?.mapNotNull { decodeEventIdAsHexOrNull(it.trim()) } - ?.takeIf { it.isNotEmpty() } + ?.map { raw -> + decodeEventIdAsHexOrNull(raw.trim()) + ?: throw IllegalArgumentException("--id expects note/nevent/naddr/64-hex, got '${raw.trim()}'") + }?.takeIf { it.isNotEmpty() } val tags = args .flag("tag") @@ -127,9 +160,9 @@ object RawEventSupport { authors = authors, kinds = kinds, tags = tags, - since = args.flag("since")?.toLongOrNull(), - until = args.flag("until")?.toLongOrNull(), - limit = args.flag("limit")?.toIntOrNull(), + since = args.flag("since")?.let { it.toLongOrNull() ?: throw IllegalArgumentException("--since expects unix seconds, got '$it'") }, + until = args.flag("until")?.let { it.toLongOrNull() ?: throw IllegalArgumentException("--until expects unix seconds, got '$it'") }, + limit = args.flag("limit")?.let { it.toIntOrNull() ?: throw IllegalArgumentException("--limit expects a number, got '$it'") }, search = args.flag("search"), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt index 4fefe297d5..867e712d56 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Router.kt @@ -26,17 +26,28 @@ import com.vitorpamplona.amethyst.cli.Output * Shared sub-verb router used by every `*Commands.dispatch`. * * Maps the first token of [tail] to a handler over the remaining args. - * Empty input emits `bad_args: `; an unrecognised verb emits - * `bad_args: `. Handlers receive the args *after* the verb, - * mirroring the old hand-rolled `when (tail[0]) { … }` blocks. + * `--help` / `-h` / `help` prints the group's [help] text (falling back to + * the one-line [usage]) to stderr and exits 0. Empty input emits + * `bad_args: `; an unrecognised verb names the verbs that do exist. + * Handlers receive the args *after* the verb, mirroring the old + * hand-rolled `when (tail[0]) { … }` blocks. */ suspend fun route( name: String, tail: Array, usage: String, routes: Map) -> Int>, + help: String? = null, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", usage) - val handler = routes[tail[0]] ?: return Output.error("bad_args", "$name ${tail[0]}") + when (tail.firstOrNull()) { + null -> return Output.error("bad_args", usage) + "--help", "-h", "help" -> { + System.err.println(help ?: usage) + return 0 + } + } + val handler = + routes[tail[0]] + ?: return Output.error("bad_args", "unknown verb: $name ${tail[0]} (expected ${routes.keys.joinToString("|")})") return handler(tail.drop(1).toTypedArray()) } diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ArgsTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ArgsTest.kt new file mode 100644 index 0000000000..51a108d5e1 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ArgsTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ArgsTest { + @Test + fun flagForms() { + val args = Args(arrayOf("--name", "alice", "--about=hi there", "pos1")) + assertEquals("alice", args.flag("name")) + assertEquals("hi there", args.flag("about")) + assertEquals("pos1", args.positional(0, "first")) + } + + @Test + fun booleanFlags() { + val args = Args(arrayOf("--json-ish", "--limit", "5")) + assertTrue(args.bool("json-ish")) + assertEquals(5, args.intFlag("limit", 1)) + assertFalse(args.bool("absent")) + } + + @Test + fun doubleDashEndsFlagParsing() { + val args = Args(arrayOf("--relay", "wss://a", "--", "--not-a-flag", "-x")) + assertEquals("wss://a", args.flag("relay")) + assertEquals(listOf("--not-a-flag", "-x"), args.positional) + } + + @Test + fun nonNumericIntFlagThrows() { + val args = Args(arrayOf("--limit", "ten")) + assertFailsWith { args.intFlag("limit", 1) } + } + + @Test + fun nonNumericLongFlagThrows() { + val args = Args(arrayOf("--timeout", "soon")) + assertFailsWith { args.longFlag("timeout", 1) } + } + + @Test + fun absentNumericFlagsFallBackToDefault() { + val args = Args(arrayOf()) + assertEquals(7, args.intFlag("limit", 7)) + assertEquals(9L, args.longFlag("timeout", 9)) + } + + @Test + fun requireFlagThrowsWithFlagNameInMessage() { + val e = assertFailsWith { Args(arrayOf()).requireFlag("server") } + assertTrue(e.message!!.contains("--server")) + } + + @Test + fun missingPositionalThrows() { + assertFailsWith { Args(arrayOf()).positional(0, "text") } + assertNull(Args(arrayOf()).positionalOrNull(0)) + } + + @Test + fun rejectUnknownFlagsTypo() { + val args = Args(arrayOf("--limt", "5")) + args.intFlag("limit", 1) + val e = assertFailsWith { args.rejectUnknown() } + assertTrue(e.message!!.contains("--limt")) + } + + @Test + fun rejectUnknownPassesWhenAllFlagsRead() { + val args = Args(arrayOf("--limit", "5", "--force")) + args.intFlag("limit", 1) + args.bool("force") + args.rejectUnknown() // must not throw + } + + @Test + fun rejectUnknownHonoursAlsoAllowed() { + val args = Args(arrayOf("--relay", "wss://a")) + args.rejectUnknown("relay") // read by a helper later — declared instead + } + + @Test + fun rejectUnknownAlwaysAllowsHelp() { + val args = Args(arrayOf("--help")) + args.rejectUnknown() + assertTrue(args.help) + } + + @Test + fun conditionalReadStillRegisters() { + // flag() records the name even when the flag is absent from argv. + val args = Args(arrayOf("--mint", "https://m")) + assertNull(args.flag("mints")) + assertEquals("https://m", args.flag("mint")) + args.rejectUnknown() + } +} diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt new file mode 100644 index 0000000000..7991e5372a --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/CliResult.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Files + +/** + * Drives [runCli] in-process with captured stdout/stderr and an isolated + * `~/.amy` (via the `amy.home` system-property seam in [DataDir.DEFAULT_ROOT]). + * The global [Output.mode] is reset around every run so `--json` in one + * invocation cannot leak into the next. + */ +data class CliResult( + val exit: Int, + val stdout: String, + val stderr: String, +) { + val stdoutLines: List get() = stdout.trim().lines().filter { it.isNotBlank() } +} + +fun amy(vararg argv: String): CliResult { + val outBuf = ByteArrayOutputStream() + val errBuf = ByteArrayOutputStream() + val prevOut = System.out + val prevErr = System.err + val prevHome = System.getProperty("amy.home") + val tempHome = Files.createTempDirectory("amy-test").toFile() + System.setProperty("amy.home", tempHome.absolutePath) + Output.mode = Output.Mode.TEXT + return try { + System.setOut(PrintStream(outBuf, true, Charsets.UTF_8)) + System.setErr(PrintStream(errBuf, true, Charsets.UTF_8)) + val exit = runCli(argv.toList().toTypedArray()) + CliResult(exit, outBuf.toString(Charsets.UTF_8), errBuf.toString(Charsets.UTF_8)) + } finally { + System.setOut(prevOut) + System.setErr(prevErr) + Output.mode = Output.Mode.TEXT + if (prevHome == null) System.clearProperty("amy.home") else System.setProperty("amy.home", prevHome) + tempHome.deleteRecursively() + } +} diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ExitCodeContractTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ExitCodeContractTest.kt new file mode 100644 index 0000000000..ed9cb29e33 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/ExitCodeContractTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The documented exit-code contract (README/DEVELOPMENT): 0 success, + * 1 runtime error, 2 bad arguments, 124 timeout. These tests pin the + * codes an interop script keys on. + */ +class ExitCodeContractTest { + @Test + fun noSubcommandExits2() { + assertEquals(2, amy().exit) + } + + @Test + fun unknownSubcommandExits2() { + val r = amy("frobnicate") + assertEquals(2, r.exit) + assertTrue(r.stderr.contains("unknown subcommand")) + } + + @Test + fun unknownSubcommandPrintsShortVerbListNotFullUsage() { + val r = amy("frobnicate") + // The full reference is ~400 lines; the error path must stay one screen. + assertTrue(r.stderr.lines().size < 30, "expected a short verb list, got ${r.stderr.lines().size} lines") + } + + @Test + fun missingPositionalExits2() { + assertEquals(2, amy("decode").exit) + } + + @Test + fun unknownSubVerbExits2() { + val r = amy("notes", "bogusverb") + assertEquals(2, r.exit) + assertTrue(r.stderr.contains("bad_args")) + } + + @Test + fun nonNumericFilterFlagExits2() { + assertEquals(2, amy("filter", "--limit", "ten").exit) + } + + @Test + fun unresolvableAuthorErrorsInsteadOfSilentlyDropping() { + // Historically `--author bob@example.com` was silently dropped and the + // query ran unfiltered — the worst possible failure mode for a script. + val r = amy("filter", "--author", "bob@example.com") + assertEquals(2, r.exit) + assertTrue(r.stderr.contains("--author")) + } + + @Test + fun unknownFlagTypoExits2() { + val r = amy("key", "generate", "--limt", "5") + assertEquals(2, r.exit) + assertTrue(r.stderr.contains("--limt")) + } + + @Test + fun topLevelHelpExits0() { + assertEquals(0, amy("--help").exit) + } + + @Test + fun groupHelpExits0AndDoesNotRun() { + val r = amy("notes", "--help") + assertEquals(0, r.exit) + assertTrue(r.stderr.contains("post"), "group help should list its sub-verbs") + } + + @Test + fun flatCommandHelpExits0AndDoesNotRun() { + // `fetch --help` used to RUN A REAL NETWORK FETCH. It must print usage + // and exit 0 without touching the network (an isolated empty ~/.amy + + // instant return is the observable proof). + val r = amy("fetch", "--help") + assertEquals(0, r.exit) + assertTrue(r.stderr.contains("fetch")) + assertTrue(r.stdout.isBlank(), "help must not emit a result object") + } + + @Test + fun powMineTimeoutExits124() { + val template = """{"created_at":1,"kind":1,"tags":[],"content":"x"}""" + val pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + val r = amy("pow", "mine", "--target", "60", "--timeout", "1", "--pubkey", pubkey, template) + assertEquals(124, r.exit) + } + + @Test + fun jsonErrorsAreSingleJsonObjectOnStderr() { + val r = amy("--json", "decode") + assertEquals(2, r.exit) + assertTrue(r.stdout.isBlank(), "errors must not write stdout") + val errLines = + r.stderr + .trim() + .lines() + .filter { it.isNotBlank() && it.startsWith("{") } + assertEquals(1, errLines.size, "expected exactly one JSON error object on stderr, got: ${r.stderr}") + val parsed = Output.mapper.readTree(errLines.single()) + assertEquals("bad_args", parsed["error"].asText()) + } + + @Test + fun errorsAreNotDoubleReported() { + // Args used to print a plain-text line AND throw (reported again by + // main) — two error lines per failure. + val r = amy("decode") + val errorLines = + r.stderr + .trim() + .lines() + .filter { it.contains("missing", ignoreCase = true) } + assertEquals(1, errorLines.size, "expected one error line, got: ${r.stderr}") + } +} diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/JsonContractTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/JsonContractTest.kt new file mode 100644 index 0000000000..96f9a4be72 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/JsonContractTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import com.fasterxml.jackson.databind.JsonNode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the `--json` machine contract for the deterministic stateless + * primitives: one JSON object, one line, stable snake_case keys. Uses the + * NIP-19 spec test vectors so the goldens are protocol-anchored, not + * implementation-anchored. + */ +class JsonContractTest { + private val vectorHex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val vectorNpub = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + + private fun jsonOf(r: CliResult): JsonNode { + assertEquals(0, r.exit, "expected success, stderr: ${r.stderr}") + assertEquals(1, r.stdoutLines.size, "expected exactly one stdout line, got: ${r.stdout}") + return Output.mapper.readTree(r.stdoutLines.single()) + } + + private fun assertSnakeCaseKeys(node: JsonNode) { + node.fieldNames().forEach { key -> + assertTrue(key.matches(Regex("[a-z0-9_]+")), "non-snake_case key: $key") + } + } + + @Test + fun encodeNpubMatchesNip19Vector() { + val json = jsonOf(amy("--json", "encode", "npub", vectorHex)) + assertEquals(vectorNpub, json["npub"].asText()) + assertSnakeCaseKeys(json) + } + + @Test + fun decodeNpubMatchesNip19Vector() { + val json = jsonOf(amy("--json", "decode", vectorNpub)) + assertTrue(json.toString().contains(vectorHex), "decode result should carry the hex pubkey: $json") + assertSnakeCaseKeys(json) + } + + @Test + fun keyGenerateThenPublicRoundTrips() { + val generated = jsonOf(amy("--json", "key", "generate")) + val nsec = generated["nsec"].asText() + val pubkey = generated["pubkey"].asText() + assertTrue(nsec.startsWith("nsec1")) + assertEquals(64, pubkey.length) + assertSnakeCaseKeys(generated) + + val derived = jsonOf(amy("--json", "key", "public", nsec)) + assertEquals(pubkey, derived["pubkey"].asText()) + } + + @Test + fun filterAssemblesTheFlagsItWasGiven() { + val json = jsonOf(amy("--json", "filter", "--kind", "1,7", "--limit", "5")) + val kinds = json["kinds"].map { it.asInt() } + assertEquals(listOf(1, 7), kinds) + assertEquals(5, json["limit"].asInt()) + } + + @Test + fun verifyReportsIdAndSignatureSeparately() { + // A structurally valid event with a wrong id/signature must still parse + // and report the two checks as fields, not crash. + val bogus = + """{"id":"${"0".repeat(64)}","pubkey":"$vectorHex","created_at":1,"kind":1,"tags":[],"content":"x","sig":"${"0".repeat(128)}"}""" + val r = amy("--json", "verify", bogus) + val line = + r.stdoutLines.singleOrNull() ?: r.stderr + .trim() + .lines() + .last() + val json = Output.mapper.readTree(line) + assertTrue(json.has("id_ok") || json.has("error"), "verify should report id_ok/signature_ok or a structured error: $json") + } + + @Test + fun textModeAndJsonModeCarrySameData() { + val json = jsonOf(amy("--json", "encode", "npub", vectorHex)) + val text = amy("encode", "npub", vectorHex) + assertEquals(0, text.exit) + assertTrue(text.stdout.contains(json["npub"].asText()), "text render should carry the same npub") + } +}