Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion lib/core/models/available_servers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,36 @@ class SelectionHistory {
consecutiveFailures: json["consecutive_failures"] ?? 0,
userFailures:
(json["user_failures"] as List<dynamic>?)
?.map((e) => DateTime.parse(e as String))
?.map(_userFailureAt)
.whereType<DateTime>()
.toList() ??
const [],
updatedAt: json["updated_at"] == null
? null
: DateTime.parse(json["updated_at"]),
);

/// Timestamp of one `user_failures` entry, or null if it has none we can
/// read. lantern-box v0.0.104 changed each entry from a bare RFC3339 string
/// to a `{"at": ..., "kind": ...}` object; both are accepted so this parses
/// against either version, matching Go's `UserFailure.UnmarshalJSON`.
/// Unreadable entries are dropped rather than thrown on: one entry in the
/// shape this side didn't expect used to fail `Server.fromJson`, and since
/// `AvailableServers.fromJson` maps over the whole list, that cost the client
/// every server it had. `kind` is dropped too — nothing here reads it.
static DateTime? _userFailureAt(dynamic entry) {
final at = entry is Map ? entry["at"] : entry;
return at is String ? DateTime.tryParse(at) : null;
}

Map<String, dynamic> toJson() => {
"last_success_delay_ms": lastSuccessDelayMs,
if (lastOutcomeAt != null)
"last_outcome_at": lastOutcomeAt!.toIso8601String(),
"consecutive_failures": consecutiveFailures,
// Bare timestamps, i.e. the pre-v0.0.104 shape, which Go still accepts.
// Nothing sends this back to Go today; emitting the object form would mean
// inventing a `kind` we never parsed.
if (userFailures.isNotEmpty)
"user_failures": userFailures.map((e) => e.toIso8601String()).toList(),
if (updatedAt != null) "updated_at": updatedAt!.toIso8601String(),
Expand Down
138 changes: 138 additions & 0 deletions test/core/models/available_servers_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:lantern/core/models/available_servers.dart';

Expand Down Expand Up @@ -259,8 +261,144 @@ void main() {
expect(s.shouldWarnBeforeManualSelection, isFalse);
});
});

group('user_failures deserialization', () {
// lantern-box v0.0.104 changed each entry from a bare RFC3339 timestamp to
// a {"at": ..., "kind": ...} object. Both have to parse: the object form is
// what current lantern-box sends, the string form is what a client paired
// with a pre-v0.0.104 one would see.
test('parses the object form', () {
final history = SelectionHistory.fromJson({
'last_success_delay_ms': 2556,
'consecutive_failures': 2,
'user_failures': [
{'at': '2026-07-23T19:23:18.956396324+08:00', 'kind': 'unknown'},
{'at': '2026-07-23T19:24:11.696763492+08:00', 'kind': 'stall'},
],
'updated_at': '2026-07-23T19:26:52.297493639+08:00',
});

expect(history.userFailures, [
DateTime.parse('2026-07-23T19:23:18.956396324+08:00'),
DateTime.parse('2026-07-23T19:24:11.696763492+08:00'),
]);
});

test('parses an object form with no kind', () {
final history = SelectionHistory.fromJson({
'user_failures': [
{'at': '2026-07-06T12:00:00Z'},
],
});

expect(history.userFailures, [DateTime.parse('2026-07-06T12:00:00Z')]);
});

test('parses the legacy bare-timestamp form', () {
final history = SelectionHistory.fromJson({
'user_failures': ['2026-07-06T12:00:00Z', '2026-07-06T12:01:00Z'],
});

expect(history.userFailures, [
DateTime.parse('2026-07-06T12:00:00Z'),
DateTime.parse('2026-07-06T12:01:00Z'),
]);
});

test('parses a window holding both forms at once', () {
final history = SelectionHistory.fromJson({
'user_failures': [
'2026-07-06T12:00:00Z',
{'at': '2026-07-06T12:01:00Z', 'kind': 'reset'},
],
});

expect(history.userFailures, [
DateTime.parse('2026-07-06T12:00:00Z'),
DateTime.parse('2026-07-06T12:01:00Z'),
]);
});

test('an absent window parses as empty', () {
expect(SelectionHistory.fromJson({}).userFailures, isEmpty);
});

// A window this side can't read must cost us the entries, not the server.
test('drops entries with no readable timestamp', () {
final history = SelectionHistory.fromJson({
'user_failures': [
{'kind': 'stall'},
{'at': null},
{'at': 1752580800},
'the beginning of time',
42,
null,
{'at': '2026-07-06T12:00:00Z', 'kind': 'stall'},
],
});

expect(history.userFailures, [DateTime.parse('2026-07-06T12:00:00Z')]);
});

// Freshdesk #180591: a single server carrying the object form threw during
// Server.fromJson, and because AvailableServers.fromJson maps over the
// whole list, that one entry took down all 55 servers — leaving the client
// with no server list at all.
test('parses a list where only one server carries user_failures', () {
final servers = AvailableServers.fromJson(
_payload([
_serverJson(tag: 'no-failures'),
_serverJson(
tag: 'has-failures',
userFailures: [
{'at': '2026-07-23T19:23:18.956396324+08:00', 'kind': 'unknown'},
],
),
]),
);

expect(servers.servers.map((s) => s.tag), [
'no-failures',
'has-failures',
]);
expect(
servers.serverByTag('has-failures')!.selectionHistory!.userFailures,
hasLength(1),
);
});
});
}

/// Round-trips through JSON so element types match what the platform channel
/// hands us — `List<dynamic>` of `Map<String, dynamic>`, nested maps likewise —
/// rather than the sharper literal types the analyzer infers here.
List<dynamic> _payload(List<Map<String, dynamic>> servers) =>
jsonDecode(jsonEncode(servers)) as List<dynamic>;

/// One server as it looks after `jsonDecode` of the payload radiance sends.
Map<String, dynamic> _serverJson({
required String tag,
List<Map<String, dynamic>>? userFailures,
}) => {
'tag': tag,
'type': 'samizdat',
'isLantern': true,
'outbound': {'type': 'samizdat', 'tag': tag, 'server': '203.0.113.7'},
'location': {
'country': 'U.S.A.',
'city': 'Chicago',
'country_code': 'US',
'latitude': 41.8781,
'longitude': -87.6298,
},
'selection_history': {
'last_success_delay_ms': 1264,
'last_outcome_at': '2026-07-23T19:26:51.648896087+08:00',
'user_failures': ?userFailures,
'updated_at': '2026-07-23T19:26:51.648896087+08:00',
},
Comment thread
myleshorton marked this conversation as resolved.
};

Server _server({
required String tag,
bool isLantern = true,
Expand Down
Loading