From 580b2e48599c2a90959e7f2b8e11ad49fd943bd1 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 21 Oct 2025 17:25:11 -0400 Subject: [PATCH] progressed events, filters. --- event.go | 14 +- event_json_test.go | 53 +++ event_sign_test.go | 35 ++ event_test.go | 35 +- filter.go | 269 ++++++++++++++ filter_json_test.go | 751 ++++++++++++++++++++++++++++++++++++++ filter_match_test.go | 399 ++++++++++++++++++++ testdata/test_events.json | 134 +++++++ util_test.go | 64 ++++ 9 files changed, 1714 insertions(+), 40 deletions(-) create mode 100644 event_json_test.go create mode 100644 event_sign_test.go create mode 100644 filter.go create mode 100644 filter_json_test.go create mode 100644 filter_match_test.go create mode 100644 testdata/test_events.json diff --git a/event.go b/event.go index 7d91dc4..331e24d 100644 --- a/event.go +++ b/event.go @@ -12,13 +12,13 @@ import ( ) type Event struct { - ID string - PubKey string - CreatedAt int - Kind int - Tags [][]string - Content string - Sig string + ID string `json:"id"` + PubKey string `json:"pubkey"` + CreatedAt int `json:"created_at"` + Kind int `json:"kind"` + Tags [][]string `json:"tags"` + Content string `json:"content"` + Sig string `json:"sig"` } var ( diff --git a/event_json_test.go b/event_json_test.go new file mode 100644 index 0000000..b5e85b2 --- /dev/null +++ b/event_json_test.go @@ -0,0 +1,53 @@ +package roots + +import ( + "encoding/json" + "testing" +) + +func TestUnmarshalEventJSON(t *testing.T) { + event := Event{} + json.Unmarshal(testEventJSONBytes, &event) + if err := event.Validate(); err != nil { + t.Error("unmarshalled event is invalid") + } + expectEqualEvents(t, event, testEvent) +} + +func TestMarshalEventJSON(t *testing.T) { + eventJSONBytes, err := json.Marshal(testEvent) + expectOk(t, err) + expectEqualStrings(t, string(eventJSONBytes), testEventJSON) +} + +func TestEventJSONRoundTrip(t *testing.T) { + event := Event{ + ID: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad", + PubKey: testEvent.PubKey, + CreatedAt: testEvent.CreatedAt, + Kind: testEvent.Kind, + Tags: [][]string{ + {"a", "value"}, + {"b", "value", "optional"}, + {"name", "value", "optional", "optional"}, + }, + Content: testEvent.Content, + Sig: "c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557", + } + expectedJSON := `{"id":"86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[["a","value"],["b","value","optional"],["name","value","optional","optional"]],"content":"hello world","sig":"c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"}` + + if err := event.Validate(); err != nil { + t.Error("test event is invalid") + } + eventJSON, err := json.Marshal(event) + expectOk(t, err) + expectEqualStrings(t, string(eventJSON), expectedJSON) + + unmarshalledEvent := Event{} + json.Unmarshal(eventJSON, &unmarshalledEvent) + + if err := unmarshalledEvent.Validate(); err != nil { + t.Error("unmarshalled event is invalid") + } + expectEqualEvents(t, unmarshalledEvent, event) +} diff --git a/event_sign_test.go b/event_sign_test.go new file mode 100644 index 0000000..5574e50 --- /dev/null +++ b/event_sign_test.go @@ -0,0 +1,35 @@ +package roots + +import ( + "testing" +) + +func TestSignEvent(t *testing.T) { + eventID := testEvent.ID + expectedSig := testEvent.Sig + computedSig, err := SignEvent(eventID, testSK) + + expectOk(t, err) + expectEqualStrings(t, computedSig, expectedSig) +} + +func TestSignInvalidEventID(t *testing.T) { + badEventID := "thisisabadeventid" + expectedError := "invalid event id hex" + + _, err := SignEvent(badEventID, testSK) + + expectError(t, err) + expectErrorSubstring(t, err, expectedError) +} + +func TestSignInvalidPrivateKey(t *testing.T) { + eventID := testEvent.ID + badSK := "thisisabadsecretkey" + expectedError := "invalid private key hex" + + _, err := SignEvent(eventID, badSK) + + expectError(t, err) + expectErrorSubstring(t, err, expectedError) +} diff --git a/event_test.go b/event_test.go index e4de478..31fa9b2 100644 --- a/event_test.go +++ b/event_test.go @@ -1,9 +1,5 @@ package roots -import ( - "testing" -) - const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167" const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" @@ -17,32 +13,5 @@ var testEvent = Event{ Sig: "83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a", } -func TestSignEvent(t *testing.T) { - eventID := testEvent.ID - expectedSig := testEvent.Sig - computedSig, err := SignEvent(eventID, testSK) - - expectOk(t, err) - expectEqualStrings(t, computedSig, expectedSig) -} - -func TestSignInvalidEventID(t *testing.T) { - badEventID := "thisisabadeventid" - expectedError := "invalid event id hex" - - _, err := SignEvent(badEventID, testSK) - - expectError(t, err) - expectErrorSubstring(t, err, expectedError) -} - -func TestSignInvalidPrivateKey(t *testing.T) { - eventID := testEvent.ID - badSK := "thisisabadsecretkey" - expectedError := "invalid private key hex" - - _, err := SignEvent(eventID, badSK) - - expectError(t, err) - expectErrorSubstring(t, err, expectedError) -} +var testEventJSON = `{"id":"c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[],"content":"hello world","sig":"83b71e15649c9e9da362c175f988c36404cabf357a976d869102a74451cfb8af486f6088b5631033b4927bd46cad7a0d90d7f624aefc0ac260364aa65c36071a"}` +var testEventJSONBytes = []byte(testEventJSON) diff --git a/filter.go b/filter.go new file mode 100644 index 0000000..fd8c2e6 --- /dev/null +++ b/filter.go @@ -0,0 +1,269 @@ +package roots + +import ( + "encoding/json" + // "fmt" + "strings" +) + +type Filter struct { + IDs []string + Authors []string + Kinds []int + Since *int + Until *int + Limit *int + Tags map[string][]string + Extensions map[string]json.RawMessage +} + +func (filter Filter) MarshalJSON() ([]byte, error) { + outputMap := make(map[string]interface{}) + + // Add standard fields + if filter.IDs != nil { + outputMap["ids"] = filter.IDs + } + if filter.Authors != nil { + outputMap["authors"] = filter.Authors + } + if filter.Kinds != nil { + outputMap["kinds"] = filter.Kinds + } + if filter.Since != nil { + outputMap["since"] = *filter.Since + } + if filter.Until != nil { + outputMap["until"] = *filter.Until + } + if filter.Limit != nil { + outputMap["limit"] = *filter.Limit + } + + // Add tags + for key, values := range filter.Tags { + outputMap["#"+key] = values + } + + // Merge extensions + for key, raw := range filter.Extensions { + // Disallow standard keys in extensions + standardKeys := []string{"ids", "authors", "kinds", "since", "until", "limit"} + found := false + for _, stdKey := range standardKeys { + // Skip standard key + if key == stdKey { + found = true + break + } + } + if found { + continue + } + + // Disallow tag keys in extensions + if strings.HasPrefix(key, "#") { + continue + } + + var extValue interface{} + if err := json.Unmarshal(raw, &extValue); err != nil { + return nil, err + } + outputMap[key] = extValue + } + + return json.Marshal(outputMap) +} + +func (filter *Filter) UnmarshalJSON(data []byte) error { + // Decode into raw map + raw := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Extract standard fields + if v, ok := raw["ids"]; ok { + if err := json.Unmarshal(v, &filter.IDs); err != nil { + return err + } + delete(raw, "ids") + } + + if v, ok := raw["authors"]; ok { + if err := json.Unmarshal(v, &filter.Authors); err != nil { + return err + } + delete(raw, "authors") + } + + if v, ok := raw["kinds"]; ok { + if err := json.Unmarshal(v, &filter.Kinds); err != nil { + return err + } + delete(raw, "kinds") + } + + if v, ok := raw["since"]; ok { + if string(raw["since"]) == "null" { + filter.Since = nil + } else { + var val int + if err := json.Unmarshal(v, &val); err != nil { + return err + } + filter.Since = &val + } + delete(raw, "since") + } + + if v, ok := raw["until"]; ok { + if string(raw["until"]) == "null" { + filter.Until = nil + } else { + var val int + if err := json.Unmarshal(v, &val); err != nil { + return err + } + filter.Until = &val + } + delete(raw, "until") + } + + if v, ok := raw["limit"]; ok { + if string(raw["limit"]) == "null" { + filter.Limit = nil + } else { + var val int + if err := json.Unmarshal(v, &val); err != nil { + return err + } + filter.Limit = &val + } + delete(raw, "limit") + } + + // Extract tag fields + for key := range raw { + if strings.HasPrefix(key, "#") { + // Leave Tags as `nil` unless tag fields exist + if filter.Tags == nil { + filter.Tags = make(map[string][]string) + } + tagKey := strings.TrimPrefix(key, "#") + var tagValues []string + if err := json.Unmarshal(raw[key], &tagValues); err != nil { + return err + } + filter.Tags[tagKey] = tagValues + delete(raw, key) + } + } + + // Place remaining fields in extensions + if len(raw) > 0 { + filter.Extensions = raw + } + + return nil +} + +func (filter Filter) Matches(event Event) bool { + // Check ID + if len(filter.IDs) > 0 { + if !matchesPrefix(event.ID, filter.IDs) { + return false + } + } + + // Check Author + if len(filter.Authors) > 0 { + if !matchesPrefix(event.PubKey, filter.Authors) { + return false + } + } + + // Check Kind + if len(filter.Kinds) > 0 { + if !matchesKinds(event.Kind, filter.Kinds) { + return false + } + } + + // Check Timestamp + if !matchesTimeRange(event.CreatedAt, filter.Since, filter.Until) { + return false + } + + // Check Tags + if len(filter.Tags) > 0 { + if !matchesTags(event.Tags, filter.Tags) { + return false + } + } + + return true +} + +func matchesPrefix(candidate string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(candidate, prefix) { + return true + } + } + return false +} + +func matchesKinds(candidate int, kinds []int) bool { + for _, kind := range kinds { + if candidate == kind { + return true + } + } + return false +} + +func matchesTimeRange(timestamp int, since *int, until *int) bool { + if since != nil && timestamp < *since { + return false + } + if until != nil && timestamp > *until { + return false + } + return true +} + +func matchesTags(eventTags [][]string, filterTags map[string][]string) bool { + for tagName, filterValues := range filterTags { + if len(filterValues) == 0 { + return true + } + + found := false + for _, eventTag := range eventTags { + if len(eventTag) < 2 { + continue + } + if eventTag[0] != tagName { + continue + } + + for _, filterValue := range filterValues { + if eventTag[1] == filterValue { + found = true + break + } + } + if found { + break + } + } + + if !found { + return false + } + } + + return true +} diff --git a/filter_json_test.go b/filter_json_test.go new file mode 100644 index 0000000..a7346f0 --- /dev/null +++ b/filter_json_test.go @@ -0,0 +1,751 @@ +package roots + +import ( + "encoding/json" + //"fmt" + "reflect" + "testing" +) + +// Types + +type FilterMarshalTestCase struct { + name string + filter Filter + expected string +} + +type FilterUnmarshalTestCase struct { + name string + input string + expected Filter +} + +type FilterRoundTripTestCase struct { + name string + filter Filter +} + +// Test Cases + +var marshalTestCases = []FilterMarshalTestCase{ + { + name: "empty filter", + filter: Filter{}, + expected: `{}`, + }, + + // ID cases + { + name: "nil IDs", + filter: Filter{IDs: nil}, + expected: `{}`, + }, + + { + name: "empty IDs", + filter: Filter{IDs: []string{}}, + expected: `{"ids":[]}`, + }, + + { + name: "populated IDs", + filter: Filter{IDs: []string{"abc", "123"}}, + expected: `{"ids":["abc","123"]}`, + }, + + // Author cases + { + name: "nil Authors", + filter: Filter{Authors: nil}, + expected: `{}`, + }, + + { + name: "empty Authors", + filter: Filter{Authors: []string{}}, + expected: `{"authors":[]}`, + }, + + { + name: "populated Authors", + filter: Filter{Authors: []string{"abc", "123"}}, + expected: `{"authors":["abc","123"]}`, + }, + + // Kind cases + { + name: "nil Kinds", + filter: Filter{Kinds: nil}, + expected: `{}`, + }, + + { + name: "empty Kinds", + filter: Filter{Kinds: []int{}}, + expected: `{"kinds":[]}`, + }, + + { + name: "populated Kinds", + filter: Filter{Kinds: []int{1, 20001}}, + expected: `{"kinds":[1,20001]}`, + }, + + // Since cases + { + name: "nil Since", + filter: Filter{Since: nil}, + expected: `{}`, + }, + + { + name: "populated Since", + filter: Filter{Since: intPtr(1000)}, + expected: `{"since":1000}`, + }, + + // Until cases + { + name: "nil Until", + filter: Filter{Until: nil}, + expected: `{}`, + }, + + { + name: "populated Until", + filter: Filter{Until: intPtr(1000)}, + expected: `{"until":1000}`, + }, + + // Limit cases + { + name: "nil Limit", + filter: Filter{Limit: nil}, + expected: `{}`, + }, + + { + name: "populated Limit", + filter: Filter{Limit: intPtr(100)}, + expected: `{"limit":100}`, + }, + + // All standard fields + { + name: "all standard fields", + filter: Filter{ + IDs: []string{"abc", "123"}, + Authors: []string{"def", "456"}, + Kinds: []int{1, 200, 3000}, + Since: intPtr(1000), + Until: intPtr(2000), + Limit: intPtr(100), + }, + expected: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`, + }, + + { + name: "mixed fields", + filter: Filter{IDs: nil, Authors: []string{}, Kinds: []int{1}}, + expected: `{"authors":[],"kinds":[1]}`, + }, + + // Tags + { + name: "nil tags map", + filter: Filter{Tags: nil}, + expected: `{}`, + }, + + { + name: "single-letter tag", + filter: Filter{Tags: map[string][]string{ + "e": {"event1"}, + }}, + expected: `{"#e":["event1"]}`, + }, + + { + name: "multi-letter tag", + filter: Filter{Tags: map[string][]string{ + "emoji": {"🔥", "💧"}, + }}, + expected: `{"#emoji":["🔥","💧"]}`, + }, + + { + name: "empty tag array", + filter: Filter{Tags: map[string][]string{ + "p": {}, + }}, + expected: `{"#p":[]}`, + }, + + { + name: "multiple tags", + filter: Filter{Tags: map[string][]string{ + "e": {"event1", "event2"}, + "p": {"pubkey1", "pubkey2"}, + }}, + expected: `{"#e":["event1","event2"],"#p":["pubkey1","pubkey2"]}`, + }, + + // Extensions + { + name: "simple extension", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"query"`), + }, + }, + expected: `{"search":"query"}`, + }, + + { + name: "extension with nested object", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "meta": json.RawMessage(`{"author":"alice","score":99}`), + }, + }, + expected: `{"meta":{"author":"alice","score":99}}`, + }, + + { + name: "extension with nested array", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "items": json.RawMessage(`[1,2,3]`), + }, + }, + expected: `{"items":[1,2,3]}`, + }, + + { + name: "extension with complex nested structure", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "data": json.RawMessage(`{"users":[{"id":1}],"count":5}`), + }, + }, + expected: `{"data":{"users":[{"id":1}],"count":5}}`, + }, + + { + name: "multiple extensions", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"x"`), + "depth": json.RawMessage(`3`), + }, + }, + expected: `{"search":"x","depth":3}`, + }, + + // Extension Collisions + { + name: "extension collides with standard field - IDs", + filter: Filter{ + IDs: []string{"real"}, + Extensions: map[string]json.RawMessage{ + "ids": json.RawMessage(`["fake"]`), + }, + }, + expected: `{"ids":["real"]}`, + }, + + { + name: "extension collides with standard field - Since", + filter: Filter{ + Since: intPtr(100), + Extensions: map[string]json.RawMessage{ + "since": json.RawMessage(`999`), + }, + }, + expected: `{"since":100}`, + }, + + { + name: "extension collides with multiple standard fields", + filter: Filter{ + Authors: []string{"a"}, + Kinds: []int{1}, + Extensions: map[string]json.RawMessage{ + "authors": json.RawMessage(`["b"]`), + "kinds": json.RawMessage(`[2]`), + }, + }, + expected: `{"authors":["a"],"kinds":[1]}`, + }, + + { + name: "extension collides with tag field - #e", + filter: Filter{ + Extensions: map[string]json.RawMessage{ + "#e": json.RawMessage(`["fakeevent"]`), + }, + }, + expected: `{}`, + }, + + { + name: "extension collides with standard and tag fields", + filter: Filter{ + Authors: []string{"realauthor"}, + Tags: map[string][]string{ + "e": {"realevent"}, + }, + Extensions: map[string]json.RawMessage{ + "authors": json.RawMessage(`["fakeauthor"]`), + "#e": json.RawMessage(`["fakeevent"]`), + }, + }, + expected: `{"authors":["realauthor"],"#e":["realevent"]}`, + }, + + // Kitchen Sink + { + name: "filter with all field types", + filter: Filter{ + IDs: []string{"x"}, + Since: intPtr(100), + Tags: map[string][]string{ + "e": {"y"}, + }, + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"z"`), + "ids": json.RawMessage(`["fakeid"]`), + }, + }, + expected: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`, + }, +} + +var unmarshalTestCases = []FilterUnmarshalTestCase{ + { + name: "empty object", + input: `{}`, + expected: Filter{}, + }, + + // ID cases + { + name: "null IDs", + input: `{"ids": null}`, + expected: Filter{IDs: nil}, + }, + + { + name: "empty IDs", + input: `{"ids": []}`, + expected: Filter{IDs: []string{}}, + }, + + { + name: "populated IDs", + input: `{"ids": ["abc","123"]}`, + expected: Filter{IDs: []string{"abc", "123"}}, + }, + + // Author cases + { + name: "null Authors", + input: `{"authors": null}`, + expected: Filter{Authors: nil}, + }, + + { + name: "empty Authors", + input: `{"authors": []}`, + expected: Filter{Authors: []string{}}, + }, + + { + name: "populated Authors", + input: `{"authors": ["abc","123"]}`, + expected: Filter{Authors: []string{"abc", "123"}}, + }, + + // Kind cases + { + name: "null Kinds", + input: `{"kinds": null}`, + expected: Filter{Kinds: nil}, + }, + + { + name: "empty Kinds", + input: `{"kinds": []}`, + expected: Filter{Kinds: []int{}}, + }, + + { + name: "populated Kinds", + input: `{"kinds": [1,2,3]}`, + expected: Filter{Kinds: []int{1, 2, 3}}, + }, + + // Since cases + { + name: "null Since", + input: `{"since": null}`, + expected: Filter{Since: nil}, + }, + + { + name: "populated Since", + input: `{"since": 1000}`, + expected: Filter{Since: intPtr(1000)}, + }, + + // Until cases + { + name: "null Until", + input: `{"until": null}`, + expected: Filter{Until: nil}, + }, + + { + name: "populated Until", + input: `{"until": 1000}`, + expected: Filter{Until: intPtr(1000)}, + }, + + // Limit cases + { + name: "null Limit", + input: `{"limit": null}`, + expected: Filter{Limit: nil}, + }, + + { + name: "populated Limit", + input: `{"limit": 1000}`, + expected: Filter{Limit: intPtr(1000)}, + }, + + // All standard fields + { + name: "all standard fields", + input: `{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}`, + expected: Filter{ + IDs: []string{"abc", "123"}, + Authors: []string{"def", "456"}, + Kinds: []int{1, 200, 3000}, + Since: intPtr(1000), + Until: intPtr(2000), + Limit: intPtr(100), + }, + }, + + { + name: "mixed fields", + input: `{"ids": null, "authors": [], "kinds": [1]}`, + expected: Filter{IDs: nil, Authors: []string{}, Kinds: []int{1}}, + }, + + { + name: "zero int pointers", + input: `{"since": 0, "until": 0, "limit": 0}`, + expected: Filter{Since: intPtr(0), Until: intPtr(0), Limit: intPtr(0)}, + }, + + // Tags + { + name: "single-letter tag", + input: `{"#e":["event1"]}`, + expected: Filter{Tags: map[string][]string{"e": {"event1"}}}, + }, + + { + name: "multi-letter tag", + input: `{"#emoji":["🔥","💧"]}`, + expected: Filter{Tags: map[string][]string{"emoji": {"🔥", "💧"}}}, + }, + + { + name: "empty tag array", + input: `{"#p":[]}`, + expected: Filter{Tags: map[string][]string{"p": {}}}, + }, + + { + name: "multiple tags", + input: `{"#p":["pubkey1","pubkey2"],"#e":["event1","event2"]}`, + expected: Filter{Tags: map[string][]string{ + "p": {"pubkey1", "pubkey2"}, + "e": {"event1", "event2"}, + }}, + }, + + { + name: "null tag", + input: `{"#p":null}`, + expected: Filter{Tags: map[string][]string{"p": nil}}, + }, + + // Extensions + { + name: "simple extension", + input: `{"search":"query"}`, + expected: Filter{Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"query"`), + }, + }, + }, + + { + name: "extension with nested object", + input: `{"meta":{"author":"alice","score":99}}`, + expected: Filter{ + Extensions: map[string]json.RawMessage{ + "meta": json.RawMessage(`{"author":"alice","score":99}`), + }, + }, + }, + + { + name: "extension with nested array", + input: `{"items":[1,2,3]}`, + expected: Filter{ + Extensions: map[string]json.RawMessage{ + "items": json.RawMessage(`[1,2,3]`), + }, + }, + }, + + { + name: "extension with complex nested structure", + input: `{"data":{"level1":{"level2":[{"id":1}]}}}`, + expected: Filter{ + Extensions: map[string]json.RawMessage{ + "data": json.RawMessage(`{"level1":{"level2":[{"id":1}]}}`), + }, + }, + }, + + { + name: "multiple extensions", + input: `{"search":"x","custom":true,"depth":3}`, + expected: Filter{ + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"x"`), + "custom": json.RawMessage(`true`), + "depth": json.RawMessage(`3`), + }, + }, + }, + + { + name: "extension with null value", + input: `{"optional":null}`, + expected: Filter{ + Extensions: map[string]json.RawMessage{ + "optional": json.RawMessage(`null`), + }, + }, + }, + + // Kitchen Sink + { + name: "extension with null value", + input: `{"ids":["x"],"since":100,"#e":["y"],"search":"z"}`, + expected: Filter{ + IDs: []string{"x"}, + Since: intPtr(100), + Tags: map[string][]string{ + "e": {"y"}, + }, + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"z"`), + }, + }, + }, +} + +var roundTripTestCases = []FilterRoundTripTestCase{ + { + name: "fully populated filter", + filter: Filter{ + IDs: []string{"x"}, + Since: intPtr(100), + Tags: map[string][]string{ + "e": {"y"}, + }, + Extensions: map[string]json.RawMessage{ + "search": json.RawMessage(`"z"`), + }, + }, + }, +} + +// Tests + +func TestFilterMarshalJSON(t *testing.T) { + for _, tc := range marshalTestCases { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.filter.MarshalJSON() + expectOk(t, err) + + var expectedMap, resultMap map[string]interface{} + err = json.Unmarshal([]byte(tc.expected), &expectedMap) + expectOk(t, err) + err = json.Unmarshal(result, &resultMap) + expectOk(t, err) + + if !reflect.DeepEqual(expectedMap, resultMap) { + t.Errorf("marshal mismatch: got %s, want %s", result, tc.expected) + } + }) + } +} + +func TestFilterUnmarshalJSON(t *testing.T) { + for _, tc := range unmarshalTestCases { + t.Run(tc.name, func(t *testing.T) { + var result Filter + err := result.UnmarshalJSON([]byte(tc.input)) + expectOk(t, err) + + expectEqualFilters(t, result, tc.expected) + }) + } +} + +func TestFilterRoundTrip(t *testing.T) { + for _, tc := range roundTripTestCases { + t.Run(tc.name, func(t *testing.T) { + jsonBytes, err := tc.filter.MarshalJSON() + expectOk(t, err) + + var result Filter + err = result.UnmarshalJSON(jsonBytes) + expectOk(t, err) + + expectEqualFilters(t, result, tc.filter) + }) + } + +} + +// Helpers + +func expectEqualFilters(t *testing.T, got, want Filter) { + // Compare IDs + if got.IDs == nil && want.IDs == nil { + // pass + } else if got.IDs == nil || want.IDs == nil { + t.Errorf("mismatched ids: got %v, want %v", got.IDs, want.IDs) + } else { + expectEqualStringSlices(t, got.IDs, want.IDs) + } + + // Compare Authors + if got.Authors == nil && want.Authors == nil { + // pass + } else if got.Authors == nil || want.Authors == nil { + t.Errorf("mismatched authors: got %v, want %v", got.Authors, want.Authors) + } else { + expectEqualStringSlices(t, got.Authors, want.Authors) + } + + // Compare Kinds + if got.Kinds == nil && want.Kinds == nil { + // pass + } else if got.Kinds == nil || want.Kinds == nil { + t.Errorf("mismatched kinds: got %v, want %v", got.Kinds, want.Kinds) + } else { + expectEqualIntSlices(t, got.Kinds, want.Kinds) + } + + // Compare Timestamps + if got.Since == nil && want.Since == nil { + // pass + } else if got.Since == nil || want.Since == nil { + t.Errorf("mismatched since pointers: got %v, want %v", got.Since, want.Since) + } else { + expectEqualIntPointers(t, got.Since, want.Since) + } + + if got.Until == nil && want.Until == nil { + // pass + } else if got.Until == nil || want.Until == nil { + t.Errorf("mismatched until pointers: got %v, want %v", got.Until, want.Until) + } else { + expectEqualIntPointers(t, got.Until, want.Until) + } + + // Compare Limit + if got.Limit == nil && want.Limit == nil { + // pass + } else if got.Limit == nil || want.Limit == nil { + t.Errorf("mismatched limit pointers: got %v, want %v", got.Limit, want.Limit) + } else { + expectEqualIntPointers(t, got.Limit, want.Limit) + } + + // Compare Tags + if got.Tags == nil && want.Tags == nil { + // pass + } else if got.Tags == nil || want.Tags == nil { + t.Errorf("mismatched tags: got %v, want %v", got.Tags, want.Tags) + } else { + expectEqualTags(t, got.Tags, want.Tags) + } + + // Compare Extensions + if got.Extensions == nil && want.Extensions == nil { + // pass + } else if got.Extensions == nil || want.Extensions == nil { + t.Errorf("mismatched extensions: got %v, want %v", got.Extensions, want.Extensions) + } else { + expectEqualExtensions(t, got.Extensions, want.Extensions) + } +} + +func expectEqualTags(t *testing.T, got, want map[string][]string) { + if len(got) != len(want) { + t.Errorf("length mismatch: got %d, want %d", len(got), len(want)) + } + for key, wantValues := range want { + gotValues, exists := got[key] + if !exists { + t.Fatalf("expected key %q to exist", key) + } + if len(wantValues) != len(gotValues) { + t.Errorf( + "key %q: length mismatch: got %d, want %d", + key, len(gotValues), len(wantValues)) + } + for i := range wantValues { + if gotValues[i] != wantValues[i] { + t.Errorf( + "key %q: index %d: got %s, want %s", + key, i, gotValues[i], wantValues[i]) + } + } + } +} + +func expectEqualExtensions(t *testing.T, got, want map[string]json.RawMessage) { + if len(got) != len(want) { + t.Errorf("length mismatch: got %d, want %d", len(got), len(want)) + } + for key, wantValue := range want { + gotValue, ok := got[key] + if !ok { + t.Errorf("expected key %s, got nil", key) + } + var gotJSON, wantJSON interface{} + if err := json.Unmarshal(wantValue, &wantJSON); err != nil { + t.Errorf("key %q: failed to unmarshal 'want' value: %s", key, wantValue) + } + if err := json.Unmarshal(gotValue, &gotJSON); err != nil { + t.Errorf("key %q: failed to unmarshal 'got' value: %s", key, wantValue) + } + if !reflect.DeepEqual(gotJSON, wantJSON) { + t.Errorf("key %q: got %s, want %s", key, gotValue, wantValue) + } + } +} diff --git a/filter_match_test.go b/filter_match_test.go new file mode 100644 index 0000000..8e37794 --- /dev/null +++ b/filter_match_test.go @@ -0,0 +1,399 @@ +package roots + +import ( + "encoding/json" + "os" + "testing" +) + +var testEvents []Event + +func init() { + data, err := os.ReadFile("testdata/test_events.json") + if err != nil { + panic(err) + } + if err := json.Unmarshal(data, &testEvents); err != nil { + panic(err) + } +} + +var ( + nayru_sk = "1784be782585dfa97712afe12585d13ee608b624cf564116fa143c31a124d31e" + nayru_pk = "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe" + farore_sk = "03d0611c41048a9108a75bf5d023180b5cf2d2d24e2e6b83def29de977315bb3" + farore_pk = "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9" + din_sk = "7547dd630c04fde72bff3b99c481c683479966cb758f0b367b08fc971ead18f0" + din_pk = "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7" +) + +type FilterTestCase struct { + name string + filter Filter + matchingIDs []string +} + +var filterTestCases = []FilterTestCase{ + { + name: "empty filter", + filter: Filter{}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "empty id", + filter: Filter{IDs: []string{}}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "single id prefix", + filter: Filter{IDs: []string{"e751d41f"}}, + matchingIDs: []string{"e751d41f"}, + }, + + { + name: "single full id", + filter: Filter{IDs: []string{"e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"}}, + matchingIDs: []string{"e67fa7b8"}, + }, + + { + name: "multiple id prefixes", + filter: Filter{IDs: []string{"562bc378", "5e4c64f1"}}, + matchingIDs: []string{"562bc378", "5e4c64f1"}, + }, + + { + name: "no id match", + filter: Filter{IDs: []string{"ffff"}}, + matchingIDs: []string{}, + }, + + { + name: "empty author", + filter: Filter{Authors: []string{}}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "single author prefix", + filter: Filter{Authors: []string{"d877e187"}}, + matchingIDs: []string{"e751d41f", "562bc378", "e67fa7b8"}, + }, + + { + name: "multiple author prefixex", + filter: Filter{Authors: []string{"d877e187", "9e4b726a"}}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + }, + }, + + { + name: "single author full", + filter: Filter{Authors: []string{"d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe"}}, + matchingIDs: []string{"e751d41f", "562bc378", "e67fa7b8"}, + }, + + { + name: "no author match", + filter: Filter{Authors: []string{"ffff"}}, + matchingIDs: []string{}, + }, + + { + name: "empty kind", + filter: Filter{Kinds: []int{}}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "single kind", + filter: Filter{Kinds: []int{1}}, + matchingIDs: []string{"562bc378", "7a5d83d4", "4b03b69a"}, + }, + + { + name: "multiple kinds", + filter: Filter{Kinds: []int{0, 2}}, + matchingIDs: []string{ + "e751d41f", + "e67fa7b8", + "5e4c64f1", + "3a122100", + "4a15d963", + "d39e6f3f", + }, + }, + + { + name: "no kind match", + filter: Filter{Kinds: []int{99}}, + matchingIDs: []string{}, + }, + + { + name: "since only", + filter: Filter{Since: intPtr(5000)}, + matchingIDs: []string{ + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "until only", + filter: Filter{Until: intPtr(3000)}, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + }, + }, + + { + name: "time range", + filter: Filter{ + Since: intPtr(4000), + Until: intPtr(6000), + }, + matchingIDs: []string{ + "5e4c64f1", + "7a5d83d4", + "3a122100", + }, + }, + + { + name: "outside time range", + filter: Filter{ + Since: intPtr(10000), + }, + matchingIDs: []string{}, + }, + + { + name: "empty tag filter", + filter: Filter{ + Tags: map[string][]string{ + "e": {}, + }, + }, + matchingIDs: []string{ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + }, + }, + + { + name: "single letter tag filter: e", + filter: Filter{ + Tags: map[string][]string{ + "e": {"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}, + }, + }, + matchingIDs: []string{"562bc378"}, + }, + + { + name: "multiple tag matches", + filter: Filter{ + Tags: map[string][]string{ + "e": { + "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", + "ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7", + }, + }, + }, + matchingIDs: []string{"562bc378", "3a122100"}, + }, + + { + name: "multiple tag matches - single event match", + filter: Filter{ + Tags: map[string][]string{ + "e": { + "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", + "cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f", + }, + }, + }, + matchingIDs: []string{"562bc378"}, + }, + + { + name: "single letter tag filter: p", + filter: Filter{ + Tags: map[string][]string{ + "p": {"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}, + }, + }, + matchingIDs: []string{"e67fa7b8"}, + }, + + { + name: "multi letter tag filter", + filter: Filter{ + Tags: map[string][]string{ + "emoji": {"🌊"}, + }, + }, + matchingIDs: []string{"e67fa7b8"}, + }, + + { + name: "multiple tag filters", + filter: Filter{ + Tags: map[string][]string{ + "e": {"ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"}, + "p": {"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"}, + }, + }, + matchingIDs: []string{"3a122100"}, + }, + + { + name: "prefix tag filter", + filter: Filter{ + Tags: map[string][]string{ + "p": {"ae3f2a91"}, + }, + }, + matchingIDs: []string{}, + }, + + { + name: "unknown tag filter", + filter: Filter{ + Tags: map[string][]string{ + "z": {"anything"}, + }, + }, + matchingIDs: []string{}, + }, + + { + name: "combined author+kind tag filter", + filter: Filter{ + Authors: []string{"d877e187"}, + Kinds: []int{1, 2}, + }, + matchingIDs: []string{ + "562bc378", + "e67fa7b8", + }, + }, + + { + name: "combined kind+time range tag filter", + filter: Filter{ + Kinds: []int{0}, + Since: intPtr(2000), + Until: intPtr(7000), + }, + matchingIDs: []string{ + "5e4c64f1", + "4a15d963", + }, + }, + + { + name: "combined author+tag tag filter", + filter: Filter{ + Authors: []string{"e719e8f8"}, + Tags: map[string][]string{ + "power": {"fire"}, + }, + }, + matchingIDs: []string{ + "4a15d963", + }, + }, + + { + name: "combined tag filter", + filter: Filter{ + Authors: []string{"e719e8f8"}, + Kinds: []int{0}, + Since: intPtr(5000), + Until: intPtr(10000), + Tags: map[string][]string{ + "power": {"fire"}, + }, + }, + matchingIDs: []string{ + "4a15d963", + }, + }, +} + +func TestEventFilterMatching(t *testing.T) { + for _, tc := range filterTestCases { + t.Run(tc.name, func(t *testing.T) { + matchedIDs := []string{} + for _, event := range testEvents { + if tc.filter.Matches(event) { + matchedIDs = append(matchedIDs, event.ID[:8]) + } + } + + expectEqualStringSlices(t, matchedIDs, tc.matchingIDs) + }) + } +} diff --git a/testdata/test_events.json b/testdata/test_events.json new file mode 100644 index 0000000..afc1f37 --- /dev/null +++ b/testdata/test_events.json @@ -0,0 +1,134 @@ +[ + { + "kind": 0, + "id": "e751d41fa31e3a115634b41fb587cbd8270d10333a6d5330b1de24737448de70", + "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", + "created_at": 1000, + "tags": [], + "content": "Nayru profile", + "sig": "b3ba1ef2b4143e8c2fabc66bfd26839d6f3a14b5f8d24a8b96ce9c1aa41a53536444be61ed3e502cbeb04d34f8b893c84fa40bac408878c57ee4054d629c1452" + }, + { + "kind": 1, + "id": "562bc378fc1a254b053b0cc1b8d61afec8e931ba79f0110ba9dd617496260758", + "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", + "created_at": 2000, + "tags": [ + [ + "e", + "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36" + ], + [ + "e", + "cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f" + ] + ], + "content": "Hello from Nayru", + "sig": "18e48bf6be4e4104f95bfe90bd61e33c3d8cc5bf3e776ba8182fafe3f84b2e4ef6ce10256865cce556016e1b14ebad3079d3d0a3afcb0f690f12fa01e8f64201" + }, + { + "kind": 2, + "id": "e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b", + "pubkey": "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", + "created_at": 3000, + "tags": [ + [ + "emoji", + "🌊" + ], + [ + "p", + "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60" + ] + ], + "content": "Nayru recommends", + "sig": "00e2c74374670b7623b793ddf4e9903ace17be621bbad74b808232eec1473271fa3e3d5e4ad01100f6c48bf36baa4e4dbaa012cd5ff060b644caac4e9a9c6b1e" + }, + { + "kind": 0, + "id": "5e4c64f15a1ad510409e5cb3dc519dcde5416fbb8621bf65559f6b98f729a0d4", + "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", + "created_at": 4000, + "tags": [ + [ + "website", + "example.com" + ] + ], + "content": "Farore profile", + "sig": "6998b03fba4787ca6a44c4042143592bb9670ded905c06c1b258a7c1630666d7b033b7f5586f7a64ed92e912b555193112e8a590326f38809c46fe104907823e" + }, + { + "kind": 1, + "id": "7a5d83d475576963f81e21d67208d6cf90c42b6a0c3a642c100a3571c5c96b68", + "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", + "created_at": 5000, + "tags": [], + "content": "Farore's message", + "sig": "e9f4986264c7eb7800b7a7d0e0de2928242cb4e93f8ba099fc1564b893dd7a77d2277dc3e8b67724c3887ccadbf14a656c80a229107eb2b5a44a20a00bc436d6" + }, + { + "kind": 2, + "id": "3a122100196b065ec6c5e1e75dd5140eeb292ef96d2acd56354eb8c23c47649a", + "pubkey": "9e4b726ab0f25af580bdd2fd504fb245cf604f1fbc2482b89cf74beb4fb3aca9", + "created_at": 6000, + "tags": [ + [ + "category", + "music", + "art" + ], + [ + "e", + "ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7" + ], + [ + "p", + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + ] + ], + "content": "Multi-tag event", + "sig": "1f5ccdd14b1313a39b6fabfc85a3535ba4f10ad99067803804c9478d63ef2cf53723fcee7041fcbaad4f846d4500183e92305d59b3e6ccb504ce291ad7f982e2" + }, + { + "kind": 0, + "id": "4a15d963de8d26e8c4377e17fcf6daec499c454338951716a7d14cae1f7be835", + "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", + "created_at": 7000, + "tags": [ + [ + "location", + "hyrule" + ], + [ + "power", + "fire" + ] + ], + "content": "Din profile", + "sig": "5a731404105aee9a04bd4d05024cb994a8d500edfceaeb83773438a70d376e6bb638e82e70380558f66aa078ab01f5c4ca86d8c37d291aafb7e33da053c856a9" + }, + { + "kind": 1, + "id": "4b03b69a7e89796e1021ad3b7f914e6868a6e900b5e6edfa09d9019a05898ed3", + "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", + "created_at": 8000, + "tags": [ + [ + "e", + "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65" + ] + ], + "content": "Din speaks", + "sig": "7330fd35e0be4a2a64a940a2841474f60b15d5dec9d4c4129905d97bd91cc8e6a97eec66091580b7351a807b7c250544cf500d0e2d47f5744387b1ce4ac49c4d" + }, + { + "kind": 2, + "id": "d39e6f3f593bd754a45a6e2f77b1b0669cdfe89c19fb2a4b252ea095caa9874b", + "pubkey": "e719e8f83b77a9efacb29fd19118b030cbf7cfbca1f8d3694235707ee213abc7", + "created_at": 9000, + "tags": [], + "content": "Final event", + "sig": "917d3fa8111cd9dfdc9acad121e7f71e4358d6a4eb0979eadc744b55f78d2647bf839282f6c10afacd64798007c3ef09b8a925c9b73f97c5219098eca1bacc4d" + } +] diff --git a/util_test.go b/util_test.go index 480f53c..c787215 100644 --- a/util_test.go +++ b/util_test.go @@ -5,6 +5,10 @@ import ( "testing" ) +func intPtr(i int) *int { + return &i +} + func expectOk(t *testing.T, err error) { if err != nil { t.Errorf("got error: %s", err.Error()) @@ -28,3 +32,63 @@ func expectEqualStrings(t *testing.T, got, want string) { t.Errorf("got %s, want %s", got, want) } } + +func expectEqualIntPointers(t *testing.T, got, want *int) { + if *got != *want { + t.Errorf("got %d, want %d", *got, *want) + } +} + +func expectEqualStringSlices(t *testing.T, got, want []string) { + if len(got) != len(want) { + t.Errorf("length mismatch: got %d, want %d", len(got), len(want)) + return + } + for i := range got { + if got[i] != want[i] { + t.Errorf("index %d: got %s, want %s", i, got[i], want[i]) + } + } +} + +func expectEqualIntSlices(t *testing.T, got, want []int) { + if len(got) != len(want) { + t.Errorf("length mismatch: got %d, want %d", len(got), len(want)) + return + } + for i := range got { + if got[i] != want[i] { + t.Errorf("index %d: got %d, want %d", i, got[i], want[i]) + } + } + +} + +func expectEqualEvents(t *testing.T, got, want Event) { + if got.ID != want.ID || + got.PubKey != want.PubKey || + got.CreatedAt != want.CreatedAt || + got.Kind != want.Kind || + got.Content != want.Content || + got.Sig != want.Sig || + !equalTags(got.Tags, want.Tags) { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func equalTags(a, b [][]string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if len(a[i]) != len(b[i]) { + return false + } + for j := range a[i] { + if a[i][j] != b[i][j] { + return false + } + } + } + return true +}