feat(core): person-to-person ratings transport (kind 30778)

Wallapop-style reputation primitive next to trust: Rating (1..5 stars +
optional comment), RatingTransport (rate/retract/ratingsOf/myRatingOf)
and a Nostr implementation on the shared channel. Addressable kind
30778, d = subject: one live rating per (rater, subject) — re-rating
replaces (a single author can never pile up reviews) and retracting
overwrites in place. Client-side dedupe mirrors NIP-01 replacement
order for relays that keep history.
This commit is contained in:
vjrj 2026-07-11 13:06:53 +02:00
parent dc7b2eee27
commit acdacf1821
6 changed files with 297 additions and 0 deletions

View file

@ -237,4 +237,75 @@ void main() {
await trust.close();
});
});
group('ratings (custom kind)', () {
test('published by raters, read back with stars and comments', () async {
final alice = await idFor(1);
final bob = await idFor(2);
final carol = await idFor(3);
final aliceR = NostrRatingTransport(await connFor(alice));
await aliceR.rate(
subjectPubkey: carol.publicKeyHex,
stars: 5,
comment: 'Lovely seeds',
);
await aliceR.close();
final bobR = NostrRatingTransport(await connFor(bob));
await bobR.rate(subjectPubkey: carol.publicKeyHex, stars: 3);
await bobR.close();
final reader = NostrRatingTransport(await connFor(await idFor(99)));
final ratings = await reader.ratingsOf(carol.publicKeyHex);
expect(ratings, hasLength(2));
final byRater = {for (final r in ratings) r.rater: r};
expect(byRater[alice.publicKeyHex]!.stars, 5);
expect(byRater[alice.publicKeyHex]!.comment, 'Lovely seeds');
expect(byRater[bob.publicKeyHex]!.stars, 3);
await reader.close();
});
test('re-rating replaces (one live rating per pair); retract removes',
() async {
final alice = await idFor(1);
final carol = await idFor(3);
final aliceR = NostrRatingTransport(await connFor(alice));
await aliceR.rate(subjectPubkey: carol.publicKeyHex, stars: 2);
// createdAt has second granularity; cross the boundary so the
// replacement is unambiguously newer.
await Future<void>.delayed(const Duration(milliseconds: 1100));
await aliceR.rate(
subjectPubkey: carol.publicKeyHex,
stars: 4,
comment: 'Better this season',
);
final mine = await aliceR.myRatingOf(carol.publicKeyHex);
expect(mine!.stars, 4);
final all = await aliceR.ratingsOf(carol.publicKeyHex);
expect(all, hasLength(1));
expect(all.single.stars, 4);
await Future<void>.delayed(const Duration(milliseconds: 1100));
await aliceR.retract(subjectPubkey: carol.publicKeyHex);
expect(await aliceR.myRatingOf(carol.publicKeyHex), isNull);
expect(await aliceR.ratingsOf(carol.publicKeyHex), isEmpty);
await aliceR.close();
});
test('rejects out-of-range stars before touching the relay', () async {
final alice = await idFor(1);
final aliceR = NostrRatingTransport(await connFor(alice));
expect(
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 0),
throwsArgumentError,
);
expect(
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 6),
throwsArgumentError,
);
await aliceR.close();
});
});
}

View file

@ -0,0 +1,29 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
Rating make(int stars, {bool retracted = false}) => Rating(
rater: 'a',
subject: 'b',
stars: stars,
ratedAt: DateTime(2026),
retracted: retracted,
);
test('accepts 1..5 stars', () {
for (var s = Rating.minStars; s <= Rating.maxStars; s++) {
expect(make(s).stars, s);
}
});
test('rejects out-of-range stars', () {
expect(() => make(0), throwsArgumentError);
expect(() => make(6), throwsArgumentError);
expect(() => make(-1), throwsArgumentError);
});
test('a retracted rating is not active', () {
expect(make(5).isActive, isTrue);
expect(make(5, retracted: true).isActive, isFalse);
});
}