Browse Source

more refactoring

imwald
Silberengel 16 hours ago
parent
commit
c4f9435303
  1. 7
      composer.json
  2. 586
      composer.lock
  3. 4
      config/services.yaml
  4. 34
      phpstan-baseline.neon
  5. 169
      src/Service/NostrArticleDiscussionSupport.php
  6. 89
      src/Service/NostrAuthorRelayCache.php
  7. 853
      src/Service/NostrClient.php
  8. 54
      src/Service/NostrKind5DeletionFilter.php
  9. 450
      src/Service/NostrWireEventMerge.php
  10. 85
      tests/Service/NostrArticleDiscussionSupportTest.php
  11. 60
      tests/Service/NostrAuthorRelayCacheTest.php
  12. 43
      tests/Service/NostrKind5DeletionFilterTest.php
  13. 97
      tests/Service/NostrWireEventMergeTest.php

7
composer.json

@ -16,11 +16,8 @@ @@ -16,11 +16,8 @@
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"embed/embed": "^4.4",
"laminas/laminas-diactoros": "^3.6",
"league/commonmark": "^2.7",
"league/html-to-markdown": "*",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0",
"league/html-to-markdown": "^5.1",
"runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.9.4",
"symfony/asset": "7.3.*",
@ -123,8 +120,6 @@ @@ -123,8 +120,6 @@
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/maker-bundle": "^1.63",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.3.*",

586
composer.lock generated

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7921811e20bbf491efec9a7357aae9ab",
"content-hash": "27cf147fe3c8ebb58922656d107bd510",
"packages": [
{
"name": "bitwasp/bech32",
@ -1665,94 +1665,6 @@ @@ -1665,94 +1665,6 @@
},
"time": "2026-01-06T11:43:05+00:00"
},
{
"name": "laminas/laminas-diactoros",
"version": "3.8.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-diactoros.git",
"reference": "60c182916b2749480895601649563970f3f12ec4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4",
"reference": "60c182916b2749480895601649563970f3f12ec4",
"shasum": ""
},
"require": {
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0"
},
"conflict": {
"amphp/amp": "<2.6.4"
},
"provide": {
"psr/http-factory-implementation": "^1.0",
"psr/http-message-implementation": "^1.1 || ^2.0"
},
"require-dev": {
"ext-curl": "*",
"ext-dom": "*",
"ext-gd": "*",
"ext-libxml": "*",
"http-interop/http-factory-tests": "^2.2.0",
"laminas/laminas-coding-standard": "~3.1.0",
"php-http/psr7-integration-tests": "^1.4.0",
"phpunit/phpunit": "^10.5.36",
"psalm/plugin-phpunit": "^0.19.5",
"vimeo/psalm": "^6.13"
},
"type": "library",
"extra": {
"laminas": {
"module": "Laminas\\Diactoros",
"config-provider": "Laminas\\Diactoros\\ConfigProvider"
}
},
"autoload": {
"files": [
"src/functions/create_uploaded_file.php",
"src/functions/marshal_headers_from_sapi.php",
"src/functions/marshal_method_from_sapi.php",
"src/functions/marshal_protocol_version_from_sapi.php",
"src/functions/normalize_server.php",
"src/functions/normalize_uploaded_files.php",
"src/functions/parse_cookie_header.php"
],
"psr-4": {
"Laminas\\Diactoros\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "PSR HTTP Message implementations",
"homepage": "https://laminas.dev",
"keywords": [
"http",
"laminas",
"psr",
"psr-17",
"psr-7"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-diactoros/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-diactoros/issues",
"rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
"source": "https://github.com/laminas/laminas-diactoros"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2025-10-12T15:31:36+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.2",
@ -2962,228 +2874,6 @@ @@ -2962,228 +2874,6 @@
},
"time": "2025-12-30T16:12:18+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jaap van Otterdijk",
"email": "opensource@ijaap.nl"
}
],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org",
"keywords": [
"FQSEN",
"phpDocumentor",
"phpdoc",
"reflection",
"static analysis"
],
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
},
"time": "2020-06-27T09:03:43+00:00"
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "5.6.7",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "31a105931bc8ffa3a123383829772e832fd8d903"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903",
"reference": "31a105931bc8ffa3a123383829772e832fd8d903",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.1",
"ext-filter": "*",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2",
"phpdocumentor/type-resolver": "^1.7",
"phpstan/phpdoc-parser": "^1.7|^2.0",
"webmozart/assert": "^1.9.1 || ^2"
},
"require-dev": {
"mockery/mockery": "~1.3.5 || ~1.6.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
"psalm/phar": "^5.26"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "me@mikevanriel.com"
},
{
"name": "Jaap van Otterdijk",
"email": "opensource@ijaap.nl"
}
],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7"
},
"time": "2026-03-18T20:47:46+00:00"
},
{
"name": "phpdocumentor/type-resolver",
"version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
"php": "^7.3 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
"phpstan/phpdoc-parser": "^1.18|^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-phpunit": "^1.1",
"phpunit/phpunit": "^9.5",
"rector/rector": "^0.13.9",
"vimeo/psalm": "^4.25"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-1.x": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"phpDocumentor\\Reflection\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "me@mikevanriel.com"
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
"source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
},
"time": "2025-11-21T15:09:14+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"doctrine/annotations": "^2.0",
"nikic/php-parser": "^5.3.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"symfony/process": "^5.2"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPStan\\PhpDocParser\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
},
"time": "2026-01-25T14:56:51+00:00"
},
{
"name": "phrity/comparison",
"version": "1.4.1",
@ -9939,68 +9629,6 @@ @@ -9939,68 +9629,6 @@
}
],
"time": "2026-03-17T21:31:11+00:00"
},
{
"name": "webmozart/assert",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4",
"reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-filter": "*",
"php": "^8.2"
},
"suggest": {
"ext-intl": "",
"ext-simplexml": "",
"ext-spl": ""
},
"type": "library",
"extra": {
"branch-alias": {
"dev-feature/2-0": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Webmozart\\Assert\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
},
{
"name": "Woody Gilk",
"email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
"keywords": [
"assert",
"check",
"validate"
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.3.0"
},
"time": "2026-04-11T10:33:05+00:00"
}
],
"packages-dev": [
@ -11884,218 +11512,6 @@ @@ -11884,218 +11512,6 @@
],
"time": "2020-09-28T06:39:44+00:00"
},
{
"name": "symfony/browser-kit",
"version": "v7.3.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/browser-kit.git",
"reference": "a1e115df7c86200f210814867a61694e6d304256"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/a1e115df7c86200f210814867a61694e6d304256",
"reference": "a1e115df7c86200f210814867a61694e6d304256",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/dom-crawler": "^6.4|^7.0"
},
"require-dev": {
"symfony/css-selector": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\BrowserKit\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/browser-kit/tree/v7.3.10"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-13T10:28:39+00:00"
},
{
"name": "symfony/css-selector",
"version": "v7.3.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "84321188c4754e64273b46b406081ad9b18e8614"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614",
"reference": "84321188c4754e64273b46b406081ad9b18e8614",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v7.3.6"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-10-29T17:24:25+00:00"
},
{
"name": "symfony/dom-crawler",
"version": "v7.3.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/dom-crawler.git",
"reference": "8d9b47c994701cd50d3507062501c1ac2b428aaf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8d9b47c994701cd50d3507062501c1ac2b428aaf",
"reference": "8d9b47c994701cd50d3507062501c1ac2b428aaf",
"shasum": ""
},
"require": {
"masterminds/html5": "^2.6",
"php": ">=8.2",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.0"
},
"require-dev": {
"symfony/css-selector": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\DomCrawler\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases DOM navigation for HTML and XML documents",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dom-crawler/tree/v7.3.10"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-05T08:45:46+00:00"
},
{
"name": "symfony/maker-bundle",
"version": "v1.67.0",

4
config/services.yaml

@ -46,6 +46,10 @@ services: @@ -46,6 +46,10 @@ services:
$defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
App\Service\NostrAuthorRelayCache:
lazy: true
arguments:
$relayQueryCache: '@cache.app'
App\Service\NostrClient:
arguments:
$projectDir: '%kernel.project_dir%'

34
phpstan-baseline.neon

@ -504,22 +504,10 @@ parameters: @@ -504,22 +504,10 @@ parameters:
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_array\(\) with list\<string\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 9
count: 6
path: src/Service/NostrClient.php
-
@ -534,12 +522,6 @@ parameters: @@ -534,12 +522,6 @@ parameters:
count: 1
path: src/Service/NostrClient.php
-
message: '#^Negated boolean expression is always true\.$#'
identifier: booleanNot.alwaysTrue
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
@ -573,7 +555,7 @@ parameters: @@ -573,7 +555,7 @@ parameters:
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 2
count: 1
path: src/Service/NostrClient.php
-
@ -582,24 +564,12 @@ parameters: @@ -582,24 +564,12 @@ parameters:
count: 1
path: src/Service/NostrClient.php
-
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Service/NostrClient.php
-
message: '#^Strict comparison using \=\=\= between non\-empty\-list\<non\-empty\-string\> and array\{\} will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^Strict comparison using \=\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\<int, mixed\>\|list\<mixed\> is not subtype of native type object\.$#'
identifier: parameter.phpDocType

169
src/Service/NostrArticleDiscussionSupport.php

@ -0,0 +1,169 @@ @@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
use swentel\nostr\Filter\Filter;
/**
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes).
* Used by {@see NostrClient::getArticleDiscussion()}.
*/
final class NostrArticleDiscussionSupport
{
/**
* @return array<int, Filter>
*/
public function createArticleDiscussionFilters(string $coordinate, ?string $rootEventHexId): array
{
$limThread = 100;
$limQuote = 80;
$filters = [];
$k1111 = KindsEnum::COMMENTS->value;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$k1 = KindsEnum::TEXT_NOTE->value;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#e', [$rootEventHexId]);
$f->setLimit($limThread);
$filters[] = $f;
}
$qKinds = [
KindsEnum::TEXT_NOTE->value,
KindsEnum::REPOST->value,
KindsEnum::GENERIC_REPOST->value,
KindsEnum::COMMENTS->value,
];
$qVals = [$coordinate];
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$qVals[] = $rootEventHexId;
}
$f = new Filter();
$f->setKinds($qKinds);
$f->setTag('#q', $qVals);
$f->setLimit($limQuote);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::GENERIC_REPOST->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit(50);
$filters[] = $f;
return $filters;
}
public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
if (($name === 'a' || $name === 'A') && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
return false;
}
public function eventIsLegacyThreadReply(object $event, string $coordinate, ?string $rootEventHexId): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if (($name === 'a' || $name === 'A') && $val === $coordinate) {
return true;
}
if ($rootEventHexId !== null && $rootEventHexId !== '' && $name === 'e' && $val === $rootEventHexId) {
return true;
}
}
return false;
}
public function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool
{
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::HIGHLIGHTS->value) {
return false;
}
if ($kind === KindsEnum::COMMENTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'q') {
$val = (string) ($tag[1] ?? '');
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if ($name === 'q') {
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
if ($kind === KindsEnum::GENERIC_REPOST->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'a' && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
}
return false;
}
}

89
src/Service/NostrAuthorRelayCache.php

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Kind-10002 (NIP-65) wss:// lists per author hex pubkey, cached. Fetches wire data via
* {@see NostrClient::getNpubRelays()}; {@see NostrClient} is injected lazily to avoid a container cycle.
*
* Intentionally not `final` so Symfony can generate a lazy proxy for this service.
*/
class NostrAuthorRelayCache
{
public function __construct(
private readonly CacheInterface $relayQueryCache,
private readonly LoggerInterface $logger,
private readonly NostrRelayListFactory $relayListFactory,
private readonly NostrClient $nostrClient,
) {
}
/**
* Full NIP-65 wss:// list for a hex pubkey, cached. Prefer {@see getTopReputableRelaysForAuthor} when
* only a few relays are needed.
*
* @return list<string>
*/
public function getAuthorNip65RelaysList(string $pubkey): array
{
$cacheKey = 'nostr_kind10002_relays_v1_'.hash('sha256', $pubkey);
return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey): array {
$item->expiresAfter(3600);
try {
$authorRelays = $this->nostrClient->getNpubRelays($pubkey);
} catch (\Exception $e) {
$this->logger->error('Error getting author NIP-65 relay list', [
'pubkey' => $pubkey,
'error' => $e->getMessage(),
]);
$authorRelays = [];
}
$authorRelays = array_values(array_filter(
$authorRelays,
static function (string $relay): bool {
return str_starts_with($relay, 'wss:')
&& !str_contains($relay, 'localhost');
}
));
if ($authorRelays === []) {
return [];
}
$seen = [];
$out = [];
foreach ($authorRelays as $u) {
if (isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$out[] = $u;
}
return $out;
});
}
/**
* A short prefix of the author NIP-65 list (or default site relay) for queries that do not need every home relay.
*
* @return list<string>
*/
public function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{
$all = $this->getAuthorNip65RelaysList($pubkey);
if ($all === []) {
return [$this->relayListFactory->getDefaultRelayUrl()];
}
if ($limit < 1) {
$limit = 1;
}
return \array_slice($all, 0, $limit);
}
}

853
src/Service/NostrClient.php

File diff suppressed because it is too large Load Diff

54
src/Service/NostrKind5DeletionFilter.php

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\KindsEnum;
/**
* NIP-09 kind-5: keep only deletion events that target kinds persisted in MySQL (profile, relay list, payto,
* long-form, magazine). Skips thread/reply deletions to reduce relay payload.
*/
final class NostrKind5DeletionFilter
{
public function isRelevantToStoredDbData(object $ev): bool
{
$kinds = $this->storedKindValues();
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'k' && \in_array((int) $r[1], $kinds, true)) {
return true;
}
if ((string) $r[0] === 'a') {
$parts = explode(':', (string) $r[1], 3);
if (\in_array((int) $parts[0], $kinds, true)) {
return true;
}
}
}
return false;
}
/**
* @return list<int>
*/
private function storedKindValues(): array
{
return [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
];
}
}

450
src/Service/NostrWireEventMerge.php

@ -0,0 +1,450 @@ @@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum;
/**
* NIP-33 / NIP-01 wire event merge, #d tags, npub→hex. See {@see NostrClient} call sites.
*/
final readonly class NostrWireEventMerge
{
private const NIP33_PARAMETERIZED_KIND_MIN = 30_000;
private const NIP33_PARAMETERIZED_KIND_MAX = 39_999;
public function __construct(
private NostrKeyHelper $keyHelper,
) {
}
public function isReplaceableByKindAndPubkeyNip(int $kind): bool
{
return $kind === 0
|| $kind === 3
|| ($kind >= 10_000 && $kind < 20_000);
}
public function isNip33ParameterizedKind(int $kind): bool
{
return $kind >= self::NIP33_PARAMETERIZED_KIND_MIN
&& $kind <= self::NIP33_PARAMETERIZED_KIND_MAX;
}
private function replaceableKindPubkeyAddressFromWire(mixed $e): ?string
{
if (!\is_object($e)) {
return null;
}
$k = (int) ($e->kind ?? 0);
if (!$this->isReplaceableByKindAndPubkeyNip($k)) {
return null;
}
$pk = (string) ($e->pubkey ?? '');
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
return (string) $k.':'.strtolower($pk);
}
private function isValidNostrEventIdString(string $id): bool
{
return 64 === \strlen($id) && ctype_xdigit($id);
}
public function wireEventSupersedes(mixed $candidate, mixed $incumbent): bool
{
$c = $this->magazineEventCreatedAt($candidate);
$i = $this->magazineEventCreatedAt($incumbent);
if ($c !== $i) {
return $c > $i;
}
$idC = $this->magazineEventId($candidate);
$idI = $this->magazineEventId($incumbent);
$vC = $this->isValidNostrEventIdString($idC);
$vI = $this->isValidNostrEventIdString($idI);
if ($vC !== $vI) {
return $vC && !$vI;
}
if (!$vC) {
if ($idC === $idI) {
return false;
}
return $idC < $idI;
}
if ($idC === $idI) {
return false;
}
return $idC < $idI;
}
private function kind0Nip01ReplaceableAddress(mixed $ev): ?string
{
if (!\is_object($ev) || (int) ($ev->kind ?? -1) !== KindsEnum::METADATA->value) {
return null;
}
$pk = (string) ($ev->pubkey ?? '');
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
return '0:'.strtolower($pk);
}
private function kind0ReplaceableIsNewer(mixed $candidate, mixed $incumbent): bool
{
return $this->wireEventSupersedes($candidate, $incumbent);
}
/**
* @param list<mixed> $events
*
* @return array<string, object>
*/
public function mergeKind0EventsByReplaceableAddress(array $events): array
{
$byAddress = [];
foreach ($events as $ev) {
$addr = $this->kind0Nip01ReplaceableAddress($ev);
if ($addr === null) {
continue;
}
if (!isset($byAddress[$addr]) || $this->kind0ReplaceableIsNewer($ev, $byAddress[$addr])) {
$byAddress[$addr] = $ev;
}
}
return $byAddress;
}
public function nip33ParameterizedReplaceableAddress(mixed $event): ?string
{
$k = $this->magazineEventKind($event);
if (!$this->isNip33ParameterizedKind($k)) {
return null;
}
$pk = $this->magazineEventPubkeyHex($event);
if ($pk === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
$d = $this->eventDTagValue($event);
if ($d === null || $d === '') {
return null;
}
return (string) $k.':'.strtolower($pk).':'.$d;
}
/**
* @param list<mixed> $events
*
* @return list<object>
*/
public function mergeNip33ParameterizedWireEvents(array $events): array
{
$byNip33Address = [];
$byKindPubkey = [];
$byId = [];
foreach ($events as $e) {
if (!\is_object($e)) {
continue;
}
$k = (int) ($e->kind ?? 0);
if ($this->isNip33ParameterizedKind($k)) {
$a = $this->nip33ParameterizedReplaceableAddress($e);
if ($a === null) {
continue;
}
if (!isset($byNip33Address[$a]) || $this->wireEventSupersedes($e, $byNip33Address[$a])) {
$byNip33Address[$a] = $e;
}
} elseif ($this->isReplaceableByKindAndPubkeyNip($k)) {
$a = $this->replaceableKindPubkeyAddressFromWire($e);
if ($a === null) {
continue;
}
if (!isset($byKindPubkey[$a]) || $this->wireEventSupersedes($e, $byKindPubkey[$a])) {
$byKindPubkey[$a] = $e;
}
} else {
$id = (string) ($e->id ?? '');
if ($id === '') {
continue;
}
if (!isset($byId[$id]) || $this->wireEventSupersedes($e, $byId[$id])) {
$byId[$id] = $e;
}
}
}
return array_values(array_merge($byId, $byKindPubkey, $byNip33Address));
}
/**
* @param list<mixed> $events
*/
public function pickLatestNip33ParameterizedForQuery(
array $events,
int $expectedKind,
string $authorHexLower,
string $dTag
): mixed {
if (!$this->isNip33ParameterizedKind($expectedKind)) {
return null;
}
$wantD = trim($dTag);
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD;
$merged = $this->mergeNip33ParameterizedWireEvents($events);
foreach ($merged as $e) {
if ($this->magazineEventKind($e) !== $expectedKind) {
continue;
}
if (strtolower($this->magazineEventPubkeyHex($e)) !== $authorHexLower) {
continue;
}
$addr = $this->nip33ParameterizedReplaceableAddress($e);
if ($addr === $expectedAddr) {
return $e;
}
}
return null;
}
/**
* @param list<mixed> $events
*/
public function pickEventForNip33OrFirst(array $events, int $kind, string $authorIdent, string $dTag): ?object
{
if ($events === []) {
return null;
}
if ($this->isNip33ParameterizedKind($kind)) {
$h = $this->authorIdentToHexLower($authorIdent);
if ($h !== null) {
$picked = $this->pickLatestNip33ParameterizedForQuery($events, $kind, $h, $dTag);
if ($picked !== null && \is_object($picked)) {
return $picked;
}
}
$merged = $this->mergeNip33ParameterizedWireEvents($events);
$first = $merged[0] ?? null;
return \is_object($first) ? $first : null;
}
if ($this->isReplaceableByKindAndPubkeyNip($kind)) {
$h = $this->authorIdentToHexLower($authorIdent);
if ($h !== null) {
$best = null;
foreach ($events as $e) {
if (!\is_object($e) || (int) ($e->kind ?? 0) !== $kind) {
continue;
}
if (strtolower((string) ($e->pubkey ?? '')) !== $h) {
continue;
}
if ($best === null || $this->wireEventSupersedes($e, $best)) {
$best = $e;
}
}
if ($best !== null) {
return $best;
}
}
foreach ($this->mergeNip33ParameterizedWireEvents($events) as $e) {
if ((int) ($e->kind ?? 0) === $kind) {
return $e;
}
}
return null;
}
$e0 = $events[0] ?? null;
return \is_object($e0) ? $e0 : null;
}
public function authorIdentToHexLower(mixed $ident): ?string
{
return $this->npubToHexPubkey($ident);
}
public function npubToHexPubkey(mixed $npub): ?string
{
$s = trim((string) $npub);
if ($s === '') {
return null;
}
if (64 === \strlen($s) && ctype_xdigit($s)) {
return strtolower($s);
}
if (str_starts_with($s, 'npub')) {
$hex = $this->keyHelper->convertToHex($s);
return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null;
}
return null;
}
public function eventDTagValue(mixed $event): ?string
{
$tags = null;
if ($event instanceof PublicationEventEntity) {
$tags = $event->getTags();
} elseif (\is_object($event) && isset($event->tags) && \is_array($event->tags)) {
$tags = $event->tags;
}
if (!\is_array($tags)) {
return null;
}
foreach ($tags as $t) {
$seq = $this->normalizeNostrTagRowToSequence($t);
if ($seq === null || ($seq[0] ?? '') !== 'd' || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
return trim((string) $seq[1]);
}
return null;
}
/**
* @return list<string>|null
*/
private function normalizeNostrTagRowToSequence(mixed $row): ?array
{
if ($row === null) {
return null;
}
if (\is_object($row)) {
$row = get_object_vars($row);
}
if (!\is_array($row) || $row === []) {
return null;
}
$seq = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
$row
)
);
if ($seq[0] === '') {
return null;
}
return $seq;
}
public function longformIngestShortSlug(string $slug, int $max = 100): string
{
$t = trim($slug);
if (strlen($t) > $max) {
return substr($t, 0, $max - 1).'…';
}
return $t;
}
/**
* @return array{kind: int, id: string, created_at: int, d: string, nip33: ?string}
*/
public function longformIngestEventWireSummary(object $e): array
{
$d = $this->eventDTagValue($e);
$nip = $this->nip33ParameterizedReplaceableAddress($e);
return [
'kind' => (int) ($e->kind ?? 0),
'id' => (string) ($e->id ?? ''),
'created_at' => (int) ($e->created_at ?? 0),
'd' => $d !== null && $d !== '' ? $this->longformIngestShortSlug($d, 80) : '',
'nip33' => $nip,
];
}
public function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity
{
if ($raw instanceof PublicationEventEntity) {
return $raw;
}
if (!\is_object($raw)) {
return null;
}
try {
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
$entity = new PublicationEventEntity();
$entity->setId((string) ($data['id'] ?? ''));
$entity->setKind((int) ($data['kind'] ?? 0));
$entity->setPubkey((string) ($data['pubkey'] ?? ''));
$entity->setContent((string) ($data['content'] ?? ''));
$entity->setCreatedAt((int) ($data['created_at'] ?? 0));
$tags = $data['tags'] ?? [];
$entity->setTags(\is_array($tags) ? $tags : []);
$entity->setSig((string) ($data['sig'] ?? ''));
return $entity;
}
public function magazineEventCreatedAt(mixed $event): int
{
if ($event instanceof PublicationEventEntity) {
return $event->getCreatedAt();
}
if (\is_object($event) && isset($event->created_at)) {
return (int) $event->created_at;
}
return 0;
}
private function magazineEventId(mixed $event): string
{
if ($event instanceof PublicationEventEntity) {
return $event->getId();
}
if (\is_object($event) && isset($event->id)) {
return (string) $event->id;
}
return '';
}
private function magazineEventKind(mixed $event): int
{
if ($event instanceof PublicationEventEntity) {
return $event->getKind();
}
if (\is_object($event) && isset($event->kind)) {
return (int) $event->kind;
}
return 0;
}
private function magazineEventPubkeyHex(mixed $event): string
{
if ($event instanceof PublicationEventEntity) {
return (string) $event->getPubkey();
}
if (\is_object($event) && isset($event->pubkey)) {
return (string) $event->pubkey;
}
return '';
}
}

85
tests/Service/NostrArticleDiscussionSupportTest.php

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\KindsEnum;
use App\Service\NostrArticleDiscussionSupport;
use PHPUnit\Framework\TestCase;
final class NostrArticleDiscussionSupportTest extends TestCase
{
private NostrArticleDiscussionSupport $s;
protected function setUp(): void
{
$this->s = new NostrArticleDiscussionSupport();
}
public function testCreateArticleDiscussionFilterCountWithoutRoot(): void
{
$c = '30023:'.str_repeat('b', 64).':my-slug';
$filters = $this->s->createArticleDiscussionFilters($c, null);
$this->assertCount(6, $filters);
}
public function testCreateArticleDiscussionFilterCountWithRoot(): void
{
$c = '30023:'.str_repeat('b', 64).':my-slug';
$root = str_repeat('c', 64);
$filters = $this->s->createArticleDiscussionFilters($c, $root);
$this->assertCount(7, $filters);
}
public function testNip22ThreadReplyByAtag(): void
{
$coord = '30023:'.str_repeat('d', 64).':x';
$e = (object) [
'kind' => KindsEnum::COMMENTS->value,
'tags' => [['A', $coord]],
];
$this->assertTrue($this->s->eventIsNip22ArticleThreadReply($e, $coord));
}
public function testLegacyThreadByAtag(): void
{
$coord = '30023:'.str_repeat('d', 64).':x';
$e = (object) [
'kind' => KindsEnum::TEXT_NOTE->value,
'tags' => [['a', $coord]],
];
$this->assertTrue($this->s->eventIsLegacyThreadReply($e, $coord, null));
}
public function testLegacyThreadByEtagWhenRootGiven(): void
{
$coord = '30023:'.str_repeat('d', 64).':x';
$root = str_repeat('e', 64);
$e = (object) [
'kind' => KindsEnum::TEXT_NOTE->value,
'tags' => [['e', $root]],
];
$this->assertTrue($this->s->eventIsLegacyThreadReply($e, $coord, $root));
}
public function testArticleQuoteByQtag(): void
{
$coord = '30023:'.str_repeat('d', 64).':x';
$e = (object) [
'kind' => KindsEnum::TEXT_NOTE->value,
'tags' => [['q', $coord]],
];
$this->assertTrue($this->s->eventIsArticleQuote($e, $coord, null));
}
public function testHighlightsAreNotQuotes(): void
{
$coord = '30023:'.str_repeat('d', 64).':x';
$e = (object) [
'kind' => KindsEnum::HIGHLIGHTS->value,
'tags' => [['q', $coord]],
];
$this->assertFalse($this->s->eventIsArticleQuote($e, $coord, null));
}
}

60
tests/Service/NostrAuthorRelayCacheTest.php

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrAuthorRelayCache;
use App\Service\NostrClient;
use App\Service\NostrRelayListFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final class NostrAuthorRelayCacheTest extends TestCase
{
public function testGetAuthorNip65RelaysListDedupesAndCaches(): void
{
$pk = str_repeat('b', 64);
$nostr = $this->createMock(NostrClient::class);
$nostr->expects($this->once())
->method('getNpubRelays')
->with($pk)
->willReturn(['wss://r1', 'wss://r2', 'wss://r1', 'http://ignored', 'wss://localhost:1']);
$ts = $this->createMock(TokenStorageInterface::class);
$ts->method('getToken')->willReturn(null);
$listFactory = new NostrRelayListFactory('wss://default', [], [], $ts, new NullLogger());
$c = new NostrAuthorRelayCache(
new ArrayAdapter(),
new NullLogger(),
$listFactory,
$nostr
);
$this->assertSame(['wss://r1', 'wss://r2'], $c->getAuthorNip65RelaysList($pk));
$this->assertSame(['wss://r1', 'wss://r2'], $c->getAuthorNip65RelaysList($pk), 'second call should use cache, not re-fetch');
}
public function testGetTopReputableRelaysForAuthorFallsBackToDefaultRelayWhenEmpty(): void
{
$pk = str_repeat('c', 64);
$nostr = $this->createMock(NostrClient::class);
$nostr->method('getNpubRelays')->willReturn([]);
$ts = $this->createMock(TokenStorageInterface::class);
$ts->method('getToken')->willReturn(null);
$listFactory = new NostrRelayListFactory('wss://main', [], [], $ts, new NullLogger());
$c = new NostrAuthorRelayCache(
new ArrayAdapter(),
new NullLogger(),
$listFactory,
$nostr
);
$this->assertSame(['wss://main'], $c->getTopReputableRelaysForAuthor($pk, 2));
}
}

43
tests/Service/NostrKind5DeletionFilterTest.php

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\KindsEnum;
use App\Service\NostrKind5DeletionFilter;
use PHPUnit\Framework\TestCase;
final class NostrKind5DeletionFilterTest extends TestCase
{
public function testKindTagMatchingStoredLongformIsRelevant(): void
{
$f = new NostrKind5DeletionFilter();
$ev = (object) [
'kind' => 5,
'tags' => [['k', (string) KindsEnum::LONGFORM->value]],
];
$this->assertTrue($f->isRelevantToStoredDbData($ev));
}
public function testKindTagForTextNoteIsNotRelevant(): void
{
$f = new NostrKind5DeletionFilter();
$ev = (object) [
'kind' => 5,
'tags' => [['k', (string) KindsEnum::TEXT_NOTE->value]],
];
$this->assertFalse($f->isRelevantToStoredDbData($ev));
}
public function testAddressTagWithStoredKindIsRelevant(): void
{
$f = new NostrKind5DeletionFilter();
$pk = str_repeat('a', 64);
$ev = (object) [
'kind' => 5,
'tags' => [['a', KindsEnum::LONGFORM->value.':'.$pk.':slug']],
];
$this->assertTrue($f->isRelevantToStoredDbData($ev));
}
}

97
tests/Service/NostrWireEventMergeTest.php

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrKeyHelper;
use App\Service\NostrWireEventMerge;
use PHPUnit\Framework\TestCase;
final class NostrWireEventMergeTest extends TestCase
{
private NostrWireEventMerge $m;
protected function setUp(): void
{
$this->m = new NostrWireEventMerge(new NostrKeyHelper());
}
public function testAuthorIdentToHexLowerAcceptsHex(): void
{
$h = str_repeat('a', 64);
$this->assertSame($h, $this->m->authorIdentToHexLower($h));
}
public function testWireEventSupersedesByCreatedAt(): void
{
$older = (object) ['kind' => 1, 'id' => str_repeat('1', 64), 'created_at' => 100, 'pubkey' => str_repeat('b', 64)];
$newer = (object) ['kind' => 1, 'id' => str_repeat('2', 64), 'created_at' => 200, 'pubkey' => str_repeat('b', 64)];
$this->assertTrue($this->m->wireEventSupersedes($newer, $older));
$this->assertFalse($this->m->wireEventSupersedes($older, $newer));
}
public function testWireEventSupersedesTieBreakByIdWhenSameCreatedAt(): void
{
$t = 50;
$a = (object) ['kind' => 1, 'id' => str_repeat('a', 64), 'created_at' => $t, 'pubkey' => str_repeat('b', 64)];
$b = (object) ['kind' => 1, 'id' => str_repeat('b', 64), 'created_at' => $t, 'pubkey' => str_repeat('b', 64)];
$this->assertTrue($this->m->wireEventSupersedes($a, $b), 'a < b lexicographically so a supersedes when created_at equal');
$this->assertFalse($this->m->wireEventSupersedes($b, $a));
}
public function testMergeNip33ParameterizedWireEventsKeepsNewerByAddress(): void
{
$k = 30_040;
$pk = str_repeat('c', 64);
$d = 'my-article';
$tags = [['d', $d]];
$old = (object) [
'kind' => $k,
'id' => str_repeat('1', 64),
'pubkey' => $pk,
'created_at' => 10,
'tags' => $tags,
];
$new = (object) [
'kind' => $k,
'id' => str_repeat('2', 64),
'pubkey' => $pk,
'created_at' => 20,
'tags' => $tags,
];
$merged = $this->m->mergeNip33ParameterizedWireEvents([$old, $new]);
$this->assertCount(1, $merged);
$this->assertSame(20, (int) $merged[0]->created_at);
}
public function testMergeKind0ByReplaceableAddress(): void
{
$pk = str_repeat('d', 64);
$a = (object) ['kind' => 0, 'id' => str_repeat('1', 64), 'pubkey' => $pk, 'created_at' => 1];
$b = (object) ['kind' => 0, 'id' => str_repeat('2', 64), 'pubkey' => $pk, 'created_at' => 2];
$out = $this->m->mergeKind0EventsByReplaceableAddress([$a, $b]);
$this->assertCount(1, $out);
$this->assertSame(2, (int) array_values($out)[0]->created_at);
}
public function testIsReplaceableByKindAndPubkeyNip(): void
{
$this->assertTrue($this->m->isReplaceableByKindAndPubkeyNip(0));
$this->assertTrue($this->m->isReplaceableByKindAndPubkeyNip(10_000));
$this->assertFalse($this->m->isReplaceableByKindAndPubkeyNip(1));
}
public function testIsNip33ParameterizedKindRange(): void
{
$this->assertTrue($this->m->isNip33ParameterizedKind(30_000));
$this->assertTrue($this->m->isNip33ParameterizedKind(39_999));
$this->assertFalse($this->m->isNip33ParameterizedKind(29_999));
$this->assertFalse($this->m->isNip33ParameterizedKind(40_000));
}
}
Loading…
Cancel
Save