delete CloudKit and added BalanceFlutter
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
This commit is contained in:
1 parent
a715eca947
commit
2c278044bb
214 files changed
+13411
-2290
No files matched your search
@@ -0,0 +1,272 @@
|
||||
// Run against a disposable BalanceServer instance:
|
||||
// dart run tool/integration_sync.dart http://127.0.0.1:18080
|
||||
// Creates two test accounts; never use a production database.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:balance_flutter/domain/finance.dart';
|
||||
import 'package:balance_flutter/services/sync_api.dart';
|
||||
|
||||
void check(bool condition, String message) {
|
||||
if (!condition) throw StateError(message);
|
||||
}
|
||||
|
||||
Future<void> unauthorized(
|
||||
Future<Object?> Function() action,
|
||||
String message,
|
||||
) async {
|
||||
try {
|
||||
await action();
|
||||
} on SyncApiException catch (error) {
|
||||
check(error.statusCode == 401, '$message: expected HTTP 401');
|
||||
return;
|
||||
}
|
||||
throw StateError('$message: unexpectedly accepted');
|
||||
}
|
||||
|
||||
Map<String, dynamic> wire(
|
||||
String entity,
|
||||
String id,
|
||||
DateTime updatedAt,
|
||||
Map<String, dynamic>? payload,
|
||||
) => {
|
||||
'entity': entity,
|
||||
'id': id,
|
||||
'updatedAt': updatedAt.toUtc().toIso8601String(),
|
||||
'deleted': payload == null,
|
||||
'payload': ?payload,
|
||||
};
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final url = arguments.isEmpty ? 'http://127.0.0.1:18080' : arguments.single;
|
||||
final uri = Uri.parse(url);
|
||||
check(
|
||||
['localhost', '127.0.0.1', '::1'].contains(uri.host),
|
||||
'Integration accounts must use a local disposable server',
|
||||
);
|
||||
final primary = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
||||
final secondDevice = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
||||
final outsider = SyncApi(serverUrl: url, allowInsecureHttp: true);
|
||||
final suffix = DateTime.now().microsecondsSinceEpoch;
|
||||
final email = 'flutter-integration-$suffix@example.com';
|
||||
const password = 'Balance integration password 2026!';
|
||||
final ledger = <String, Map<String, dynamic>>{};
|
||||
final pages = <int>[];
|
||||
var cursor = 0;
|
||||
try {
|
||||
final registered = await primary.register(email, password);
|
||||
final user = await primary.me(registered['accessToken'] as String);
|
||||
check(user['email'] == email, 'register/me account mismatch');
|
||||
var session = await secondDevice.login(email, password);
|
||||
check(
|
||||
(session['user'] as Map)['id'] == user['id'],
|
||||
'login account mismatch',
|
||||
);
|
||||
final other = await outsider.register(
|
||||
'other-$suffix@example.com',
|
||||
password,
|
||||
);
|
||||
final timestamp = DateTime.now().toUtc().subtract(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
final transactions = List.generate(
|
||||
1100,
|
||||
(index) => TransactionRecord(
|
||||
id: 'transaction-$index',
|
||||
amountMinor: 10000 + index,
|
||||
date: timestamp,
|
||||
categoryName: 'Тестовая категория 🍏',
|
||||
updatedAt: timestamp,
|
||||
note: 'UTF-8 roundtrip $index',
|
||||
kindRawValue: index.isEven ? 'expense' : 'income',
|
||||
),
|
||||
);
|
||||
final category = CategoryRecord(
|
||||
id: 'integration-category',
|
||||
name: 'Тестовая категория 🍏',
|
||||
icon: 'cart.fill',
|
||||
colorName: 'green',
|
||||
emoji: '🍏',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
final budget = BudgetRecord(
|
||||
id: 'integration-budget',
|
||||
categoryName: category.name,
|
||||
limitMinor: 999999,
|
||||
monthStart: DateTime.utc(timestamp.year, timestamp.month),
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
final changes = [
|
||||
for (final record in transactions)
|
||||
wire('transaction', record.id, record.updatedAt, record.payload()),
|
||||
wire('category', category.id, category.updatedAt, category.payload()),
|
||||
wire('budget', budget.id, budget.updatedAt, budget.payload()),
|
||||
];
|
||||
await primary.synchronize(
|
||||
accessToken: () async => registered['accessToken'] as String,
|
||||
cursor: 0,
|
||||
deviceId: 'integration-device-one',
|
||||
changes: changes,
|
||||
applyPage: (_, _) async {},
|
||||
);
|
||||
cursor = await secondDevice.synchronize(
|
||||
accessToken: () async => session['accessToken'] as String,
|
||||
cursor: 0,
|
||||
deviceId: 'integration-device-two',
|
||||
changes: [],
|
||||
applyPage: (page, nextCursor) async {
|
||||
check(nextCursor > cursor, 'Non-advancing pagination cursor');
|
||||
pages.add(page.length);
|
||||
for (final change in page) {
|
||||
ledger['${change['entity']}:${change['id']}'] = change;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
},
|
||||
);
|
||||
check(
|
||||
jsonEncode(pages) == '[500,500,102]',
|
||||
'Expected 3 pages [500,500,102], got $pages',
|
||||
);
|
||||
check(ledger.length == 1102, 'Missing records after pagination');
|
||||
final roundtrip = ledger['transaction:transaction-99']!;
|
||||
final decoded = TransactionRecord.fromPayload(
|
||||
id: roundtrip['id'] as String,
|
||||
updatedAt: DateTime.parse(roundtrip['updatedAt'] as String),
|
||||
payload: Map<String, dynamic>.from(roundtrip['payload'] as Map),
|
||||
);
|
||||
check(
|
||||
decoded.amountMinor == 10099 && decoded.categoryName == category.name,
|
||||
'Money or UTF-8 payload changed',
|
||||
);
|
||||
final changedAt = DateTime.now().toUtc();
|
||||
final edited = transactions.first.copyWith(
|
||||
amountMinor: 1234567,
|
||||
updatedAt: changedAt,
|
||||
);
|
||||
final updates = [
|
||||
wire('transaction', edited.id, edited.updatedAt, edited.payload()),
|
||||
wire('transaction', transactions[1].id, changedAt, null),
|
||||
];
|
||||
await primary.synchronize(
|
||||
accessToken: () async => registered['accessToken'] as String,
|
||||
cursor: cursor,
|
||||
deviceId: 'integration-device-one',
|
||||
changes: updates,
|
||||
applyPage: (_, _) async {},
|
||||
);
|
||||
var updateCount = 0;
|
||||
cursor = await secondDevice.synchronize(
|
||||
accessToken: () async => session['accessToken'] as String,
|
||||
cursor: cursor,
|
||||
deviceId: 'integration-device-two',
|
||||
changes: [],
|
||||
applyPage: (page, nextCursor) async {
|
||||
updateCount += page.length;
|
||||
for (final change in page) {
|
||||
ledger['${change['entity']}:${change['id']}'] = change;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
},
|
||||
);
|
||||
check(updateCount == 2, 'Expected update and tombstone');
|
||||
check(
|
||||
ledger['transaction:transaction-1']!['deleted'] == true,
|
||||
'Deletion was not propagated',
|
||||
);
|
||||
final updatedPayload =
|
||||
ledger['transaction:transaction-0']!['payload'] as Map;
|
||||
check(
|
||||
moneyToMinor(updatedPayload['amount'] as num) == 1234567,
|
||||
'Update was not propagated',
|
||||
);
|
||||
final oldCursor = cursor;
|
||||
// Replaying stale active rows must not overwrite the newer value/tombstone.
|
||||
await primary.synchronize(
|
||||
accessToken: () async => registered['accessToken'] as String,
|
||||
cursor: cursor,
|
||||
deviceId: 'integration-device-one',
|
||||
changes: changes.take(2).toList(),
|
||||
applyPage: (page, nextCursor) async {
|
||||
check(
|
||||
page.isEmpty && nextCursor == oldCursor,
|
||||
'Stale replay changed LWW state',
|
||||
);
|
||||
},
|
||||
);
|
||||
// The second account shares record IDs but must see no first-account rows.
|
||||
final isolated = await outsider.sync(
|
||||
accessToken: other['accessToken'] as String,
|
||||
cursor: 0,
|
||||
deviceId: 'integration-outsider',
|
||||
changes: [],
|
||||
);
|
||||
check(
|
||||
(isolated['changes'] as List).isEmpty && isolated['cursor'] == 0,
|
||||
'Cross-account data leak',
|
||||
);
|
||||
await outsider.sync(
|
||||
accessToken: other['accessToken'] as String,
|
||||
cursor: 0,
|
||||
deviceId: 'integration-outsider',
|
||||
pull: false,
|
||||
changes: [
|
||||
wire(
|
||||
'transaction',
|
||||
transactions.first.id,
|
||||
changedAt,
|
||||
transactions.first.copyWith(amountMinor: 1).payload(),
|
||||
),
|
||||
],
|
||||
);
|
||||
final unchanged = await primary.sync(
|
||||
accessToken: registered['accessToken'] as String,
|
||||
cursor: cursor,
|
||||
deviceId: 'integration-device-one',
|
||||
changes: [],
|
||||
);
|
||||
check(
|
||||
(unchanged['changes'] as List).isEmpty,
|
||||
'Other-account edit affected first account',
|
||||
);
|
||||
final oldRefresh = session['refreshToken'] as String;
|
||||
session = await secondDevice.refresh(oldRefresh);
|
||||
check(
|
||||
session['refreshToken'] != oldRefresh,
|
||||
'Refresh token was not rotated',
|
||||
);
|
||||
await unauthorized(
|
||||
() => secondDevice.refresh(oldRefresh),
|
||||
'Rotated refresh token',
|
||||
);
|
||||
check(
|
||||
(await secondDevice.me(session['accessToken'] as String))['id'] ==
|
||||
user['id'],
|
||||
'Refreshed token invalid',
|
||||
);
|
||||
await secondDevice.logout(session['refreshToken'] as String);
|
||||
await unauthorized(
|
||||
() => secondDevice.refresh(session['refreshToken'] as String),
|
||||
'Logged-out refresh token',
|
||||
);
|
||||
await primary.logout(registered['refreshToken'] as String);
|
||||
await outsider.logout(other['refreshToken'] as String);
|
||||
stdout.writeln(
|
||||
jsonEncode({
|
||||
'result': 'PASS',
|
||||
'server': url,
|
||||
'records': 1102,
|
||||
'pullPages': pages,
|
||||
'updateAndDelete': true,
|
||||
'staleReplayLww': true,
|
||||
'twoAccountIsolation': true,
|
||||
'refreshRotation': true,
|
||||
'logoutRevocation': true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
primary.close();
|
||||
secondDevice.close();
|
||||
outsider.close();
|
||||
}
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Disposable real-server integration run. No original server files are changed.
|
||||
set -euo pipefail
|
||||
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
server_source="${project_dir}/../BalanceServer"
|
||||
GO_BIN="${GO_BIN:-$(command -v go || true)}"
|
||||
DART_BIN="${DART_BIN:-$(command -v dart || true)}"
|
||||
if [[ -z "$GO_BIN" && -x /tmp/go/bin/go ]]; then GO_BIN=/tmp/go/bin/go; fi
|
||||
if [[ -z "$DART_BIN" && -x /tmp/flutter/bin/dart ]]; then DART_BIN=/tmp/flutter/bin/dart; fi
|
||||
[[ -n "$GO_BIN" && -x "$GO_BIN" ]] || { echo 'Go 1.25+ required (set GO_BIN).' >&2; exit 1; }
|
||||
[[ -n "$DART_BIN" && -x "$DART_BIN" ]] || { echo 'Dart required (set DART_BIN).' >&2; exit 1; }
|
||||
go_version="$("$GO_BIN" version)"
|
||||
if [[ ! "$go_version" =~ go([0-9]+)\.([0-9]+) ]] || (( BASH_REMATCH[1] < 1 || (BASH_REMATCH[1] == 1 && BASH_REMATCH[2] < 25) )); then
|
||||
echo "Go 1.25+ required; found $go_version" >&2; exit 1
|
||||
fi
|
||||
scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/balance-integration.XXXXXX")"
|
||||
server_pid=''
|
||||
cleanup() {
|
||||
if [[ -n "$server_pid" ]]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi
|
||||
rm -rf -- "$scratch_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
cp -R -- "$server_source" "$scratch_dir/server"
|
||||
mkdir -p "$scratch_dir/server/cmd/flutter-integration"
|
||||
cat > "$scratch_dir/server/cmd/flutter-integration/main.go" <<'GO'
|
||||
package main
|
||||
|
||||
import (
|
||||
"balance-server/internal/config"
|
||||
"balance-server/internal/database"
|
||||
"balance-server/internal/httpapi"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
func main() {
|
||||
cfg, err := config.Load(); if err != nil { log.Fatal(err) }
|
||||
db, err := database.Open(cfg.DatabasePath); if err != nil { log.Fatal(err) }; defer db.Close()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0"); if err != nil { log.Fatal(err) }
|
||||
if err = os.WriteFile(os.Getenv("BALANCE_READY_FILE"), []byte("http://"+listener.Addr().String()), 0600); err != nil { log.Fatal(err) }
|
||||
server := &http.Server{Handler: httpapi.New(cfg, db, slog.Default()), ReadHeaderTimeout: 5*time.Second}
|
||||
log.Fatal(server.Serve(listener))
|
||||
}
|
||||
GO
|
||||
(cd "$scratch_dir/server" && "$GO_BIN" build -o "$scratch_dir/server-bin" ./cmd/flutter-integration)
|
||||
BALANCE_DB_PATH="$scratch_dir/balance.db" \
|
||||
BALANCE_JWT_SECRET='integration-only-disposable-secret-2026-balance' \
|
||||
BALANCE_ALLOW_REGISTRATION=true BALANCE_READY_FILE="$scratch_dir/ready" \
|
||||
"$scratch_dir/server-bin" >"$scratch_dir/server.log" 2>&1 &
|
||||
server_pid=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
[[ -s "$scratch_dir/ready" ]] && break
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then cat "$scratch_dir/server.log" >&2; exit 1; fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -s "$scratch_dir/ready" ]] || { cat "$scratch_dir/server.log" >&2; echo 'Server startup timed out.' >&2; exit 1; }
|
||||
server_url="$(cat "$scratch_dir/ready")"
|
||||
cd "$project_dir"
|
||||
"$DART_BIN" run tool/integration_sync.dart "$server_url"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Resize the original Balance icon into platform assets (requires Pillow)."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
icon = Image.open(root / 'assets/app_icon.png').convert('RGB')
|
||||
def save(path, size):
|
||||
icon.resize((size,size), Image.Resampling.LANCZOS).save(path)
|
||||
for target in ['ios','macos']:
|
||||
folder = root / target / 'Runner/Assets.xcassets/AppIcon.appiconset'
|
||||
for asset in json.loads((folder/'Contents.json').read_text())['images']:
|
||||
if 'filename' in asset:
|
||||
size = round(float(asset['size'].split('x')[0]) * float(asset['scale'].rstrip('x')))
|
||||
save(folder/asset['filename'],size)
|
||||
for density,size in [('mdpi',48),('hdpi',72),('xhdpi',96),('xxhdpi',144),('xxxhdpi',192)]:
|
||||
save(root/f'android/app/src/main/res/mipmap-{density}/ic_launcher.png',size)
|
||||
for name,size in [('Icon-192',192),('Icon-512',512),('Icon-maskable-192',192),('Icon-maskable-512',512)]:
|
||||
save(root/f'web/icons/{name}.png',size)
|
||||
save(root/'web/favicon.png',32)
|
||||
icon.save(root/'windows/runner/resources/app_icon.ico',sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])
|
||||
manifest=root/'web/manifest.json'
|
||||
data=json.loads(manifest.read_text());data.update(background_color='#f4faf6',theme_color='#356859',orientation='any')
|
||||
manifest.write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n')
|
||||
Reference in new issue
Block a user