test(publish): publishing end to end against a running server
All checks were successful
CI / check (push) Successful in 16m58s
CI / deploy (push) Successful in 1m28s

A sync test of the public tier as a whole: the author's device syncs a
studyset (a topic, tagged cards, a note, an exam plan, an exam of their
own), the instance builds the first release from what it holds, another
person finds it in Explore and adds it to their library (the topic, the
tags, the note and the plan's topic come along, the exam stays the
author's), a second release is offered and taken while the learner's own
edit stays, and a third is offered no more once the author withdraws.
Until now each half had tests of its own only: the server's publish from
rows written into its database, the app's copy and update from releases
through fakes.

A test can't sign in to GitHub, so it gives the author the identified
scope by SQL, on the person and their sessions, as linking GitHub does.
The devices' clients are exposed for what lives on the instance rather
than in the synced data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 14:34:05 +02:00
commit 0a52a3947a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 289 additions and 0 deletions

View file

@ -0,0 +1,284 @@
// Publishing end to end, against a separately started server (ADR 0005,
// stages 1 to 3): the author's device syncs a studyset, the instance builds
// a release from what it holds and keeps it in git, another person finds it
// in Explore and adds it to their library, a later release comes into their
// copy as an update while their own edit stays, and once the author takes it
// off Explore, nobody finds it and the copy is offered nothing. Every device
// is its own client and local database with the app's real gateways and
// repositories on top, and syncs only when a test says so.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in two
// new anonymous people (at most 30 per IP and hour) and gives the author the
// `identified` scope by SQL on the development database, on the person and
// their sessions as linking GitHub does (`grantIdentified`), since a test
// can't sign in to GitHub. Their studyset stays in the development
// server's git repositories, withdrawn after its third release. Who may
// publish what, and what a release leaves out, are the server's tests'
// (publish_test.dart).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import '../support/memory_published_store.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess author;
late AuthSuccess learner;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
author = await signInAnonymously();
learner = await signInAnonymously();
// As `grantIdentified` does once a GitHub account is linked: the scope
// on the person, and on each of their sessions, which keep a copy.
for (final table in [
'serverpod_auth_core_user',
'serverpod_auth_core_session',
]) {
await queryServer(
'UPDATE "$table" '
'SET "scopeNames" = ("scopeNames"::jsonb || \'["identified"]\')::json '
'WHERE "${table == 'serverpod_auth_core_user' ? 'id' : 'authUserId'}" '
'= @id:uuid',
{'id': author.authUserId.uuid},
);
}
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('publish_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
/// A device of [person]'s.
Future<SyncDevice> device(AuthSuccess person, String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
/// Explore on [device], copying into its person's library.
ExploreRepository exploreOn(SyncDevice device) => ExploreRepository(
ServerpodExploreGateway(device.client),
ReleaseCopier(
device.studysets,
device.cards,
device.notes,
device.paths,
device.pdfs,
device.tags,
device.assets,
),
CopyUpdater(
device.studysets,
device.cards,
device.notes,
device.paths,
device.pdfs,
device.tags,
device.assets,
),
device.studysets,
);
CardContent card(String front, String back) => CardContent(
front: RichContent.plain(front),
back: RichContent.plain(back),
);
/// The card of [studysetId] on [device] whose front is [front].
Future<Card> cardOf(
SyncDevice device,
String studysetId,
String front,
) async => (await device.cards.cardsOf(
studysetId,
)).singleWhere((card) => card.content.front.plainText == front);
test('a studyset published from one device is found, copied and updated on '
"another person's, their own edit kept, until it is withdrawn", () async {
final laptop = await device(author, 'author-laptop');
final phone = await device(learner, 'learner-phone');
final run = Random.secure().nextInt(1 << 32).toRadixString(36);
final title = 'Japanese e2e $run';
// The author: a public profile, and a studyset under a licence that
// allows passing it on, with a topic, tagged cards, a note and an exam
// plan, and an exam of their own to prepare for.
await ProfileRepository(ServerpodProfileGateway(laptop.client)).save(
handle: 'e2e_$run',
displayName: 'End to end',
bio: '',
links: const [],
);
final root = await laptop.studysets.create(
StudysetContent(
title: title,
description: RichContent.plain('Published by the sync tests.'),
license: 'CC-BY-SA-4.0',
licenseHolder: 'End to end',
frontLanguage: 'ja',
backLanguage: 'en',
),
);
final kanji = await laptop.studysets.create(
StudysetContent.titled('Kanji'),
parentId: root,
);
final neko = await laptop.cards.create(root, card('猫', 'cat'));
final inu = await laptop.cards.create(root, card('犬', 'dog'));
await laptop.cards.create(kanji, card('食', 'eat'));
final n5 = await laptop.tags.create('JLPT-N5');
await laptop.tags.setTagsOf(neko, {n5});
await laptop.tags.setTagsOf(inu, {n5});
await laptop.notes.create(
root,
NoteContent(title: 'Particles', body: RichContent.plain('は and が')),
);
final rootContent = (await laptop.studyset(root))!.content;
await laptop.studysets.updateContent(
root,
rootContent
.withExamPlans([
ExamPlan(id: 'kanji-quiz', name: 'Kanji quiz', topics: [kanji]),
])
.withExam(
ExamChoice(plan: 'kanji-quiz', date: DateTime(2026, 12, 6)),
),
);
await laptop.sync();
final publishing = PublishRepository(
ServerpodPublishGateway(laptop.client),
MemoryPublishedStore(),
);
addTearDown(publishing.dispose);
final first = await publishing.publish(
root,
slug: 'japanese',
notes: 'The first release.',
);
expect(first.latest?.version, 1);
expect(first.latest?.cards, 3);
expect(first.location, endsWith('/@e2e_$run/japanese'));
// The learner finds it in Explore and adds it to their library.
final explore = exploreOn(phone);
final found = (await explore.list(
ExploreFilter(text: title),
)).page.singleWhere((studyset) => studyset.publicId == first.publicId);
expect(found.version, 1);
expect(found.handle, 'e2e_$run');
expect(found.cards, 3);
final copy = await explore.addToLibrary(found);
await phone.sync();
final copied = (await phone.studyset(copy))!;
expect(copied.content.title, title);
expect(copied.content.license, 'CC-BY-SA-4.0');
expect(copied.content.published?.version, 1);
expect(copied.content.publishedAt, first.location);
expect(copied.content.exam, isNull, reason: "the author's exam is theirs");
final copiedKanji = (await phone.studysets.all()).singleWhere(
(studyset) => studyset.parentId == copy,
);
expect(copiedKanji.content.title, 'Kanji');
expect(copied.content.examPlans.single.topics, [copiedKanji.id]);
final copiedNeko = await cardOf(phone, copy, '猫');
expect(
[for (final tag in (await phone.tags.read()).of(copiedNeko.id)) tag.name],
['JLPT-N5'],
);
expect(
(await cardOf(phone, copiedKanji.id, '食')).content.back.plainText,
'eat',
);
expect((await phone.notes.notesOf(copy)).single.content.title, 'Particles');
expect(await explore.updateOf(copy), isNull, reason: 'it holds the latest');
// The learner changes a card of theirs; the author another, and adds
// one, and publishes again.
await phone.cards.save(copiedNeko, card('猫', 'cat, a neko'));
await laptop.cards.save(
await cardOf(laptop, root, '犬'),
card('犬', 'dog, an inu'),
);
await laptop.cards.create(root, card('鳥', 'bird'));
await laptop.sync();
final second = await publishing.publish(
root,
slug: 'japanese',
notes: 'An inu, and a bird.',
);
expect(second.latest?.version, 2);
// The copy is offered the release, and takes it at a tap.
final offer = await explore.updateOf(copy);
expect(offer?.version, 2);
expect(offer?.added, 1);
expect(offer?.changed, greaterThanOrEqualTo(1));
expect(await explore.takeUpdate(copy, 2), 0, reason: 'nothing to settle');
await phone.sync();
expect(
(await cardOf(phone, copy, '犬')).content.back.plainText,
'dog, an inu',
);
expect(
(await cardOf(phone, copy, '猫')).content.back.plainText,
'cat, a neko',
);
expect((await cardOf(phone, copy, '鳥')).content.back.plainText, 'bird');
expect((await phone.studyset(copy))!.content.published?.version, 2);
// A third release, then withdrawn: nobody finds it, and the copy, which
// holds the second, is offered nothing.
await laptop.cards.create(root, card('魚', 'fish'));
await laptop.sync();
await publishing.publish(root, slug: 'japanese', notes: 'A fish.');
expect((await explore.updateOf(copy))?.version, 3);
final withdrawn = await publishing.withdraw(root);
expect(withdrawn.withdrawn, isTrue);
expect(
(await explore.list(ExploreFilter(text: title))).page.where(
(studyset) => studyset.publicId == first.publicId,
),
isEmpty,
);
expect(await explore.updateOf(copy), isNull);
});
}

View file

@ -208,6 +208,11 @@ final class SyncDevice(
);
}
/// The device's client, signed in as its person: for what lives on the
/// instance rather than in the synced data (profiles, publishing,
/// Explore).
protocol.Client get client => _client;
/// Sends this device's changes and receives everyone else's.
Future<void> sync() => _client.offlineSync.syncOnce(_db);