diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ef9bd09 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# go-heartwood + +## Testing +- Baseline: `go test -count=1 ./...` +- Do not use `-race`: `boltdb/bolt@v1.3.1` triggers a `checkptr` panic under race instrumentation; this is an upstream bug, not a project defect. diff --git a/cmd/gen-fixtures/main.go b/cmd/gen-fixtures/main.go new file mode 100644 index 0000000..af696e1 --- /dev/null +++ b/cmd/gen-fixtures/main.go @@ -0,0 +1,171 @@ +// gen-fixtures generates cryptographically valid Nostr test events and writes +// them to testdata/events.json. The three private keys are hardcoded so +// subsequent runs produce identical output. +// +// Usage: go run ./cmd/gen-fixtures +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + + roots "git.wisehodl.dev/jay/go-roots/events" + "git.wisehodl.dev/jay/go-roots/keys" +) + +// Hardcoded private keys — do not change; output must be deterministic. +const ( + alicePrivKey = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + bobPrivKey = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + carolPrivKey = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" +) + +type fixtureEvent struct { + Description string `json:"description"` + Event roots.Event `json:"event"` +} + +type fixtures struct { + Events map[string]fixtureEvent `json:"events"` + Keys map[string]string `json:"keys"` +} + +func mustPubKey(privKey string) string { + pk, err := keys.GetPublicKey(privKey) + if err != nil { + panic(fmt.Sprintf("GetPublicKey: %v", err)) + } + return pk +} + +func mustSign(e roots.Event, privKey string) roots.Event { + id := roots.GetID(e) + e.ID = id + sig, err := roots.SignEvent(id, privKey) + if err != nil { + panic(fmt.Sprintf("SignEvent: %v", err)) + } + e.Sig = sig + return e +} + +func build(privKey string, opts ...roots.EventOption) roots.Event { + pk := mustPubKey(privKey) + base := []roots.EventOption{roots.WithPubKey(pk), roots.WithCreatedAt(1000)} + e := roots.NewEvent(append(base, opts...)...) + return mustSign(e, privKey) +} + +func main() { + alicePub := mustPubKey(alicePrivKey) + bobPub := mustPubKey(bobPrivKey) + + // carol_placeholder must be built first; its ID is used as an e-tag value. + carolPlaceholder := build(carolPrivKey, + roots.WithKind(1), + roots.WithContent("carol placeholder"), + ) + + f := fixtures{ + Events: map[string]fixtureEvent{ + "carol_placeholder": { + Description: "Provides real event ID for e-tag tests", + Event: carolPlaceholder, + }, + "bare": { + Description: "Minimal event, no tags", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("bare"), + ), + }, + "generic_tag": { + Description: "Generic t-tag; no expander triggered", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("generic tag"), + roots.WithTag(roots.Tag{"t", "bitcoin"}), + ), + }, + "e_tag_valid": { + Description: "e-tag referencing carol_placeholder.id; triggers ExpandTaggedEvents", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("e tag valid"), + roots.WithTag(roots.Tag{"e", carolPlaceholder.ID}), + ), + }, + "e_tag_invalid": { + Description: "e-tag with non-hex value; expander skips it", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("e tag invalid"), + roots.WithTag(roots.Tag{"e", "notvalid"}), + ), + }, + "p_tag_valid": { + Description: "p-tag referencing bob's pubkey; triggers ExpandTaggedUsers", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("p tag valid"), + roots.WithTag(roots.Tag{"p", bobPub}), + ), + }, + "p_tag_invalid": { + Description: "p-tag with non-hex value; expander skips it", + Event: build(alicePrivKey, + roots.WithKind(1), + roots.WithContent("p tag invalid"), + roots.WithTag(roots.Tag{"p", "notvalid"}), + ), + }, + "replaceable_k0": { + Description: "Kind 0; triggers ExpandReplaceableEvents", + Event: build(alicePrivKey, + roots.WithKind(0), + roots.WithContent("replaceable k0"), + ), + }, + "replaceable_k3": { + Description: "Kind 3; triggers ExpandReplaceableEvents", + Event: build(alicePrivKey, + roots.WithKind(3), + roots.WithContent("replaceable k3"), + ), + }, + "replaceable_k10k": { + Description: "Kind 10002; triggers ExpandReplaceableEvents", + Event: build(alicePrivKey, + roots.WithKind(10002), + roots.WithContent("replaceable k10k"), + ), + }, + }, + Keys: map[string]string{ + "alice": alicePub, + "bob": bobPub, + "carol": mustPubKey(carolPrivKey), + }, + } + + // Resolve output path relative to project root (two levels up from this file). + _, thisFile, _, _ := runtime.Caller(0) + projectRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + outPath := filepath.Join(projectRoot, "testdata", "events.json") + + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "marshal: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(outPath, data, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write: %v\n", err) + os.Exit(1) + } + + fmt.Printf("wrote %s\n", outPath) +} diff --git a/fixtures_test.go b/fixtures_test.go new file mode 100644 index 0000000..0d240fa --- /dev/null +++ b/fixtures_test.go @@ -0,0 +1,51 @@ +package heartwood + +import ( + "encoding/json" + "os" + "testing" + + roots "git.wisehodl.dev/jay/go-roots/events" +) + +// Fixtures holds the loaded test fixture data from testdata/events.json. +type Fixtures struct { + Events map[string]FixtureEvent `json:"events"` + Keys map[string]string `json:"keys"` +} + +// FixtureEvent is a single entry from the fixtures file. +type FixtureEvent struct { + Description string `json:"description"` + Event roots.Event `json:"event"` +} + +// LoadFixtures reads testdata/events.json and returns the parsed fixtures. +// Calls t.Fatal if the file cannot be read or parsed. +func LoadFixtures(t *testing.T) Fixtures { + t.Helper() + data, err := os.ReadFile("testdata/events.json") + if err != nil { + t.Fatalf("LoadFixtures: read testdata/events.json: %v", err) + } + var f Fixtures + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("LoadFixtures: unmarshal: %v", err) + } + return f +} + +// ValidatedEvent returns the named fixture as a roots.ValidatedEvent. +// Calls t.Fatal if the key is missing or the event fails validation. +func (f Fixtures) ValidatedEvent(t *testing.T, key string) roots.ValidatedEvent { + t.Helper() + fe, ok := f.Events[key] + if !ok { + t.Fatalf("Fixtures.ValidatedEvent: key %q not found", key) + } + ve, err := roots.NewValidatedEvent(fe.Event) + if err != nil { + t.Fatalf("Fixtures.ValidatedEvent: key %q: %v", key, err) + } + return ve +} diff --git a/testdata/events.json b/testdata/events.json new file mode 100644 index 0000000..1cf3d3f --- /dev/null +++ b/testdata/events.json @@ -0,0 +1,154 @@ +{ + "events": { + "bare": { + "description": "Minimal event, no tags", + "event": { + "id": "1da6d5f9576fec76a95df494a2e4938fada64861fb5e219bc84f5096cb4afa72", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": null, + "content": "bare", + "sig": "6cfc68b98e6ea885fab7b907913ee3a6f5e2d55510720fa2a2be286ba268b26759af8560bbdaf1f73a90538d176668219371b58294850ff9c606ed5e77486d97" + } + }, + "carol_placeholder": { + "description": "Provides real event ID for e-tag tests", + "event": { + "id": "7750d2e1166793c982ab77f8c673c80ae4fcbfa7b27ada11c25f09c9ec124eb5", + "pubkey": "438a4f623099e7c238970a8481b03d449fd45cc2c2185e739b28f28ce5342bb3", + "created_at": 1000, + "kind": 1, + "tags": null, + "content": "carol placeholder", + "sig": "2a17f67aef9f1c47777610741b8bf616ac532d7a9778022cf2cf0be8cb795b10246e50c3dc6ab4b9a6c5ddab1339b83de906c44d87b9b18288a8d286cf173cbe" + } + }, + "e_tag_invalid": { + "description": "e-tag with non-hex value; expander skips it", + "event": { + "id": "bd0ab5852ebadd81aa1aed160bc8bc29df1cc6301c4e1e291753f9b7b45a74e4", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": [ + [ + "e", + "notvalid" + ] + ], + "content": "e tag invalid", + "sig": "f2584c5a648879c4f4f5f51c78bfad848b30970964d2d0e9034693d72cabceecaddbbcd7998b10e936f6aa7e6cc71ca48672f209dbc72e27d0dda8e04514627a" + } + }, + "e_tag_valid": { + "description": "e-tag referencing carol_placeholder.id; triggers ExpandTaggedEvents", + "event": { + "id": "1143f2388f4960582b78b888a6884667c501622bda3427d5d399dfaff3bfcb8b", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": [ + [ + "e", + "7750d2e1166793c982ab77f8c673c80ae4fcbfa7b27ada11c25f09c9ec124eb5" + ] + ], + "content": "e tag valid", + "sig": "9d6322bb827ce962364b39c3507c411f0ab418f86ac6a86b1a116318c98f922cbb71e67b0fe101a4fd65240ae18011cdf6fc42faa3ccc15a16d1d8151d9537e7" + } + }, + "generic_tag": { + "description": "Generic t-tag; no expander triggered", + "event": { + "id": "61a977e8af0f2903a54420503525cdba12ee0d0f2286d0d9b97d301cd0c48e78", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": [ + [ + "t", + "bitcoin" + ] + ], + "content": "generic tag", + "sig": "9b8711fd744968fb4fcb3751db716c8ff594e67435067a45db221bf8235e513ac09b5d7c906a277569dfe888ca8ac8aa9edd7b1dfd41b7e65c3fcf566909cb26" + } + }, + "p_tag_invalid": { + "description": "p-tag with non-hex value; expander skips it", + "event": { + "id": "4efbe65fd7fa349c423b400bdfdc72f4348b596cfb4a1dcffe0354898321b661", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": [ + [ + "p", + "notvalid" + ] + ], + "content": "p tag invalid", + "sig": "ee7a2958f8afb2580324b796d89dcce7ece4cffd8515170a7dda16da44281e27281acc4b2e8bf3dd687b666336c46ea8b268a3eba4b2992ab5330c8857b0b9e1" + } + }, + "p_tag_valid": { + "description": "p-tag referencing bob's pubkey; triggers ExpandTaggedUsers", + "event": { + "id": "5585db1ad4e5e8338bb316b0ba2d629a09e7a85ff13cd4e82394ebdb0ecfbe02", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 1, + "tags": [ + [ + "p", + "6aa3da9b5c1d61956076cb3014ffdaa0996bacdae29ba4b89e39b4088f86ec78" + ] + ], + "content": "p tag valid", + "sig": "96c8989f9697670ef25737745835be057063ea27d025b09e90fa7357cdf8eaa0eb7114c1f5c78b21c859e249108b0b2f9d0c2745c627199fcdd1abb4700c09f3" + } + }, + "replaceable_k0": { + "description": "Kind 0; triggers ExpandReplaceableEvents", + "event": { + "id": "980337456b70da46bdfbcebece79f80f5dcb8252b699f4ca49c1d775ee984a79", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 0, + "tags": null, + "content": "replaceable k0", + "sig": "f3f42c5ef863834ad64b161bb6c921166e36992d66a616537b55c18e7fd4d9b30635a418c788f7e0e086f63087eb8540a104dd676a18f11491a7ef91a0b7b02a" + } + }, + "replaceable_k10k": { + "description": "Kind 10002; triggers ExpandReplaceableEvents", + "event": { + "id": "03ad06615fc00dff6caae73d803896745459f47c468f1856d8c87afbd64ba85d", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 10002, + "tags": null, + "content": "replaceable k10k", + "sig": "c4f041d9911fd63125ad2b3348f766cf6895bd452adad386dbda7d57b87b151ea56faf265e707b812bf375dfb85cd0793d645f48ba7db2fc9e12c1fe79359d1e" + } + }, + "replaceable_k3": { + "description": "Kind 3; triggers ExpandReplaceableEvents", + "event": { + "id": "5db1a0d7338e83b75cd1ae233e999e16b166f96672267768f25188f1d0a4ae78", + "pubkey": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "created_at": 1000, + "kind": 3, + "tags": null, + "content": "replaceable k3", + "sig": "8738a009f80c3a58db0b88792fea36ed77ada5d6b089ff7729347a3a47f6a2fae27ad47763c59cfc4b4573121c03bb431b937419f37e919a1f4cc21d150543e0" + } + } + }, + "keys": { + "alice": "ab5d2e79cfd621b1b027ffb24e2453ed7fb571ba9a841ff0e2473466cabd168d", + "bob": "6aa3da9b5c1d61956076cb3014ffdaa0996bacdae29ba4b89e39b4088f86ec78", + "carol": "438a4f623099e7c238970a8481b03d449fd45cc2c2185e739b28f28ce5342bb3" + } +} \ No newline at end of file