mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
test(mobile): establish companion desktop filter interoperability
Link the separate buzz-app implementation and qualify the older desktop client in this repository. Exercise signed encrypted golden payloads, sibling preservation and crossed whole-record winners; document last-write-wins conflicts and recovery limits. Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -247,17 +247,39 @@ Unreads uses the shared unread computation, including manually
|
||||
marked unread channels and observed unread thread replies. Active uses verified
|
||||
message timestamps; unavailable activity leaves channels visible with Retry.
|
||||
|
||||
Mobile reads and writes the desktop-compatible encrypted kind-30078
|
||||
`channel-filter` preference for the current account and community. It preserves
|
||||
Mobile reads and writes the encrypted kind-30078 `channel-filter` preference
|
||||
for the current account and community. Its desktop counterpart is the separate
|
||||
[buzz-app implementation](https://github.com/block/buzz-app/pull/1297), including
|
||||
both browser and packaged-native writers. The older `desktop/` client in this
|
||||
repository does not implement this lane. Cross-client use requires that companion
|
||||
implementation; this PR does not add filters to the older desktop client.
|
||||
Mobile preserves
|
||||
metadata registers, null All tombstones, durations, and unrelated record fields.
|
||||
A cached copy is display-only until a complete relay read succeeds. Writes read
|
||||
the fresh record, publish one update, and confirm the mode and duration on relay
|
||||
readback. Failed saves roll back locally; the sheet retains the draft for Retry
|
||||
or Cancel.
|
||||
Modes and durations share **whole-record last-write-wins** authority: the retained
|
||||
Nostr event supplies both maps. `meta.g` supplies mode projection and tombstones,
|
||||
not a separate per-section merge protocol. A fresh preflight preserves edits it
|
||||
has seen, but crossed writes can lose an unseen edit, including another section's
|
||||
edit. Readback confirms the requested mode and duration together; load/refresh
|
||||
adopts the retained winning record. There is no conflict-free merge guarantee.
|
||||
|
||||
Within a supported v1 record, malformed groups, durations and register siblings
|
||||
are discarded while valid siblings, reset tombstones and unrelated fields remain.
|
||||
An explicit user save publishes and confirms that canonical repair. Oversized
|
||||
records and unsupported schema versions remain read-only; cached display data
|
||||
never becomes repair authority.
|
||||
|
||||
Preference refresh happens on load, foreground resume, and pull to refresh;
|
||||
instant cross-device preference synchronization is not promised. Filter choices
|
||||
are disabled while preferences are unavailable; hidden-channel navigation remains
|
||||
available. Deleting a custom section confirms its filter reset before removal.
|
||||
available. Deleting a custom section immediately persists its local removal,
|
||||
even offline. Unfiltered sections need no filter publication. Other sections get
|
||||
best-effort filter cleanup; failure reports that cleanup was unavailable without
|
||||
restoring the section. Never-reused section IDs make retained orphan filters
|
||||
unreachable; no automatic cleanup retry or offline filter-save queue is added.
|
||||
|
||||
## Checks
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:buzz/features/channels/channel_filter/channel_filter_manager.dart';
|
||||
import 'package:buzz/features/channels/channel_filter/channel_filter_store.dart';
|
||||
import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final fixture = jsonDecode(
|
||||
File('test/fixtures/channel_filter_desktop.json').readAsStringSync(),
|
||||
);
|
||||
final keys = nostr.Keys(fixture['testSecretHex'] as String);
|
||||
final crypto = ChannelSortCrypto(keys.nsec, keys.public);
|
||||
NostrEvent signed(String content, int at) => NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
kind: EventKind.readState,
|
||||
tags: [
|
||||
['d', 'channel-filter'],
|
||||
],
|
||||
content: content,
|
||||
secretKey: fixture['testSecretHex'] as String,
|
||||
createdAt: at,
|
||||
verify: false,
|
||||
).toMap(),
|
||||
);
|
||||
|
||||
test(
|
||||
'desktop encrypted golden head is adopted and preserved in mobile writes',
|
||||
() async {
|
||||
var history = [
|
||||
NostrEvent.fromJson((fixture['head'] as Map).cast<String, dynamic>()),
|
||||
];
|
||||
final manager = ChannelFilterManager(
|
||||
pubkey: keys.public,
|
||||
crypto: crypto,
|
||||
fetch: () async => history,
|
||||
cache: (_) {},
|
||||
publish: (content, at) async => history = [signed(content, at)],
|
||||
);
|
||||
await manager.refresh();
|
||||
expect(manager.ready, isTrue);
|
||||
expect(manager.store.toJson(), fixture['plaintext']);
|
||||
expect(manager.store.modeFor('channels'), ChannelFilterMode.active);
|
||||
expect(manager.store.windowFor('channels'), 604800);
|
||||
await manager.save(
|
||||
'forums',
|
||||
ChannelFilterMode.active,
|
||||
windowSeconds: 86400,
|
||||
);
|
||||
final saved = jsonDecode(crypto.decrypt(history.single.content));
|
||||
final mobileGolden = NostrEvent.fromJson(
|
||||
(fixture['mobileHead'] as Map).cast<String, dynamic>(),
|
||||
);
|
||||
nostr.Event.fromJson(jsonEncode(mobileGolden.toJson()));
|
||||
final mobilePayload = jsonDecode(crypto.decrypt(mobileGolden.content));
|
||||
expect(saved['groups'], mobilePayload['groups']);
|
||||
expect(
|
||||
saved['activeWindowSeconds'],
|
||||
mobilePayload['activeWindowSeconds'],
|
||||
);
|
||||
expect(saved['groups'], {'channels': 'active', 'forums': 'active'});
|
||||
expect(saved['activeWindowSeconds'], {
|
||||
'channels': 604800,
|
||||
'forums': 86400,
|
||||
});
|
||||
expect(
|
||||
saved['meta']['g']['channels'],
|
||||
fixture['plaintext']['meta']['g']['channels'],
|
||||
);
|
||||
manager.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'crossed edits converge to one whole-record winner with mode and duration together',
|
||||
() async {
|
||||
var head = NostrEvent.fromJson(
|
||||
(fixture['head'] as Map).cast<String, dynamic>(),
|
||||
);
|
||||
final initial = head;
|
||||
var preflights = 0;
|
||||
final bothRead = Completer<void>();
|
||||
final bothPublished = Completer<void>();
|
||||
final published = <NostrEvent>[];
|
||||
ChannelFilterManager device() => ChannelFilterManager(
|
||||
pubkey: keys.public,
|
||||
crypto: crypto,
|
||||
cache: (_) {},
|
||||
fetch: () async {
|
||||
if (preflights < 2) {
|
||||
preflights++;
|
||||
if (preflights == 2) bothRead.complete();
|
||||
await bothRead.future;
|
||||
return [initial];
|
||||
}
|
||||
return [head];
|
||||
},
|
||||
publish: (content, at) async {
|
||||
published.add(signed(content, at));
|
||||
if (published.length == 2) {
|
||||
// NIP-01: newest created_at, then lowest event ID wins.
|
||||
published.sort(
|
||||
(a, b) => a.createdAt != b.createdAt
|
||||
? b.createdAt.compareTo(a.createdAt)
|
||||
: a.id.compareTo(b.id),
|
||||
);
|
||||
head = published.first;
|
||||
bothPublished.complete();
|
||||
}
|
||||
await bothPublished.future;
|
||||
},
|
||||
)..ready = true;
|
||||
final a = device();
|
||||
final b = device();
|
||||
final results = await Future.wait([
|
||||
a
|
||||
.save('channels', ChannelFilterMode.active, windowSeconds: 86400)
|
||||
.then((_) => true, onError: (_) => false),
|
||||
b
|
||||
.save('channels', ChannelFilterMode.unreads)
|
||||
.then((_) => true, onError: (_) => false),
|
||||
]);
|
||||
expect(results.where((success) => success).length, 1);
|
||||
await a.refresh();
|
||||
await b.refresh();
|
||||
final winner = jsonDecode(crypto.decrypt(head.content));
|
||||
expect(a.store.toJson(), winner);
|
||||
expect(b.store.toJson(), winner);
|
||||
expect(
|
||||
a.store.modeFor('channels') == ChannelFilterMode.active
|
||||
? a.store.windowFor('channels')
|
||||
: b.store.windowFor('channels'),
|
||||
a.store.modeFor('channels') == ChannelFilterMode.active
|
||||
? 86400
|
||||
: 604800,
|
||||
);
|
||||
a.dispose();
|
||||
b.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
55
mobile/test/fixtures/channel_filter_desktop.json
vendored
Normal file
55
mobile/test/fixtures/channel_filter_desktop.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"producer": "block/buzz-app browser-host/sidebar-sort.mjs at 475e2afef07519b065fe65c36a63cbb01c3d333a",
|
||||
"testSecretHex": "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"head": {
|
||||
"kind": 30078,
|
||||
"content": "AiUaHZzIMpxRgskz71o22p/SmQcBDyoc+ZK13jmjZ8fS4iTUQ8UvHpihm/tPLvv8u3MRx/Lg1UVv3Ba6kPfhZrWyUp8QVOAThoaA3OSnqYn6oyiK7o2dacoj7o/mGcjumIgYMBx0bi2DOUU/1CvZd/MPNTcWT/aBx7OWHXDM7hGbP+JWupageYTBMcK3Yh4zam9F/VGGWnVA5xkAcusLHffQckk1eQbTiDj4e1S4THRv/1n9U5RFgS31A4owYf+IQqS6dvK5j+xcs9TCjgiHBmDLN1BGkpF1e+khoJD/hgXUcTbbhjMmsAuhwRr6TKu9mBUPYwJlQHhPI93qpwvyTwCqaQ==",
|
||||
"created_at": 1700000000,
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"channel-filter"
|
||||
],
|
||||
[
|
||||
"t",
|
||||
"channel-filter"
|
||||
]
|
||||
],
|
||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"id": "6e7a80b302ef51ded8d80ddd6fe5306141576cb496450d24def70bc544fd85ed",
|
||||
"sig": "2562bcb9cdfaaa6d78c50dca53ea215b055817bdfd715f7e8bdfd9a24dce88ffed0b753c0f8c91f4f3e1dc561c224da04348db31288abd1f4dbe7c603ee1ead7"
|
||||
},
|
||||
"plaintext": {
|
||||
"version": 1,
|
||||
"groups": {
|
||||
"channels": "active"
|
||||
},
|
||||
"meta": {
|
||||
"v": 1,
|
||||
"g": {
|
||||
"channels": [
|
||||
1700000000000,
|
||||
"2a60ddc6f815f2de",
|
||||
"active"
|
||||
]
|
||||
}
|
||||
},
|
||||
"activeWindowSeconds": {
|
||||
"channels": 604800
|
||||
}
|
||||
},
|
||||
"mobileHead": {
|
||||
"id": "af147924f0b3376b3bd0bb6dbfab4f403d0f7a24044a6df289b069d15e0b4325",
|
||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"created_at": 1792513727,
|
||||
"kind": 30078,
|
||||
"tags": [
|
||||
[
|
||||
"d",
|
||||
"channel-filter"
|
||||
]
|
||||
],
|
||||
"content": "Aq9hWkmNY3trUoHFyAnlb5kdFTrAeogOYD8u/CNR4vB8ayOIsf6M7QplHVP9kKMLOms1hcubPzvnMpocYuc+nITZzmNuOOjVnclgm4v850mdyha9riHF5/JLn5u/0N/Z9l6tV0iynyFAy7rkJQPq7ulKazvv5hXjlCs+Nsd6jzN2OFRoVIefatSWcmQcMyHIvzxnVw3uM8tbajQfgL49/JmLfG86js0eBQ+2LbUqxfd0T0tyYqh7CAP+HO9vLOdhDaz0Fbi5xyG756kE5vg37QaWZxU9BsmCD9Zy+NQIT2b7ywUuERCVme+qdICKTeR/Nia5O6G9ZjPMW7hfU1gdDNzxTycQ8fwuCTIXRNA3sIZQCqsxylG2GahAQaP8HwdyOJ4SMZU323JA+oK4bs2epxxs45b+z40mXaAQgvGHSvT8K4g=",
|
||||
"sig": "795bd134835f7d7130409ac1084b7bd0360d119940f0a90c525cd18575a9978c275c28c8cfdc711032987d581e4283bdcf75e98fed5847352ebb30566c177912"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user