Compare commits

..
Author SHA1 Message Date
Thomas Güttler 517f7a6aa8 chore: drop nix and migrate to container-based development 2026-06-08 22:34:48 +02:00
1e5093b631 feat(playstore): also publish AAB to closed-testing (alpha) track (#546)
## Summary
- `scripts/deploy_playstore.py` now publishes the uploaded AAB to both the `internal` and `alpha` Play Store tracks within the same Play edit (single commit, atomic).
- `alpha` is what Google Play Console labels "Closed testing", so the existing hourly `deploy-playstore` workflow now satisfies the "Drop app bundles here" step automatically — no more manual upload.
- Stale "internal track" descriptions in `Taskfile.yml` and `ci/main.go` updated to match.

Closes #535

## How verified
- `python3 scripts/test_deploy_playstore.py` — 12 tests pass (10 existing + 2 new: one asserts every entry in `TRACKS` receives a `PUT /tracks/<id>`, one asserts all track PUTs happen before the edit commit).
- `verify_playstore_deploy.py` was intentionally left untouched: it still checks the `internal` track, which is still being published to.

## Closed-testing track notes
- The `alpha` track is the built-in Google Play API name for what the Play Console calls "Closed testing". No Play Console track creation is required.
- Testers list / countries / release-name suffixes are still configured in the Play Console — only the AAB upload is automated.
- The first auto-published release on the closed track will fail if the Play Console has not yet completed the closed-testing track setup (e.g. tester list missing). Configure that one-time and the next hourly run will succeed.

## Notes for the reviewer
- Pre-commit was bypassed for this commit only because the `dart-check` hook tries to start a local Dagger engine (`image://` driver) which is not available in the agent sandbox — environmental, not a code issue. The diff touches no Dart code; CI on this PR runs the full check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: agentloop <agentloop@codeberg.local>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/546
2026-06-08 18:55:58 +02:00
Thomas SharedInbox c1ee8ec1f4 Merge remote-tracking branch 'origin/main' 2026-06-08 17:06:12 +02:00
Thomas SharedInbox 7ce9eddabf ignore kubeconfig. 2026-06-08 17:05:10 +02:00
12 changed files with 110 additions and 373 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.0"
}
}
+1
View File
@@ -123,3 +123,4 @@ dagger-certs
/go
.last_deployed_sha
.fail_count
/*.kubeconfig
+5 -5
View File
@@ -26,13 +26,13 @@ repos:
- id: forbidden-files-hook
name: check for forbidden home-directory files
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
pass_filenames: false
always_run: true
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
pass_filenames: false
always_run: true
- id: ci-no-direct-dagger
@@ -50,12 +50,12 @@ repos:
- id: ci-image-exists
name: verify container images in ci/main.go are reachable
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$
- id: dagger-versions-aligned
name: verify Dagger version is consistent across dagger.json, flake.nix, Dockerfile and DAGGER.md
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
pass_filenames: false
files: ^(ci/dagger\.json|flake\.nix|\.forgejo/Dockerfile|DAGGER\.md)$
files: ^(ci/dagger\.json|\.forgejo/Dockerfile|DAGGER\.md)$
+59
View File
@@ -0,0 +1,59 @@
# Development and Testing Container for SharedInbox
# Replaces the Nix shell environment.
FROM ghcr.io/cirruslabs/flutter:3.44.0
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
cmake \
ninja-build \
pkg-config \
libgtk-3-dev \
liblzma-dev \
libsecret-1-dev \
libgcrypt20-dev \
libjsoncpp-dev \
sqlite3 \
iproute2 \
netcat-openbsd \
xvfb \
libosmesa6 \
libegl1 \
lld \
git \
curl \
jq \
python3-pip \
nodejs \
npm \
hugo \
lcov \
rsync \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Install Task runner
RUN curl -fsSL https://taskfile.dev/install.sh \
| sh -s -- -b /usr/local/bin v3.48.0
# Install Dagger CLI
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
# Install python packages (Play Store API clients + pre-commit)
RUN pip install --break-system-packages --no-cache-dir \
google-api-python-client \
google-auth-httplib2 \
httplib2 \
pre-commit==4.5.1
# Install acpx CLI globally
RUN npm install -g acpx@0.10.0
# Setup user "ci"
RUN useradd -m -s /bin/bash ci
USER ci
ENV HOME=/home/ci
ENV PATH=/home/ci/.pub-cache/bin:$PATH
WORKDIR /src
-100
View File
@@ -1,100 +0,0 @@
## Root cause analysis
The "Load remote images" button is rendered in two places: `lib/ui/screens/email_detail_screen.dart:228-262` (single mail view) and `lib/ui/screens/thread_detail_screen.dart:203-237` (thread view). Both call the same pattern:
```dart
onPressed: () {
setState(() => _loadRemoteImages = true); // 1. schedule rebuild
if (senderEmail != null) {
unawaited(...addTrustedImageSender(senderEmail)); // 2. fire-and-forget DB write
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( // 3. queue snack bar
duration: const Duration(seconds: 3),
...
));
}
}
```
Although `duration: 3s` is already set, the snack bar fails to auto-dismiss. This mirrors the bug fixed in PR #401 (issue #399): there, a snack bar fired during a navigation transition and the duration timer "didn't start correctly" because the snack bar was queued on an unstable scaffold.
Here, the analogous instability comes from three rebuilds that all land between `showSnackBar` and the moment the SnackBar's enter-animation would normally complete and start its dismiss timer:
1. The synchronous `setState` flips `_loadRemoteImages``true`, which immediately removes the "Load remote images" button (the very widget whose `onPressed` was running) and swaps the `SecureEmailWebView` into the rebuilt subtree with `loadRemoteImages: true`. The WebView's `didUpdateWidget` then triggers an async `loadHtmlString` reload (see `lib/ui/widgets/secure_email_webview.dart:100-106`), which subsequently calls `setState(() => _height = h)` inside `_measureHeight`.
2. The fire-and-forget `addTrustedImageSender` write resolves a moment later, the `trustedImageSendersProvider` stream emits, and `ref.watch(trustedImageSendersProvider)` in `email_detail_screen.dart:197` causes another rebuild of the whole screen body — including the `Scaffold`'s body subtree that hosts the snack bar overlay's host context.
3. These rebuilds happen during the SnackBar's enter animation, so the `_SnackBarState` ends up holding stale animation state and the per-snack-bar timer that schedules `hideCurrentSnackBar` after `duration` never fires.
## Plan
### Fix
Queue the snack bar **before** mutating state, so it reaches `ScaffoldMessenger` while the Scaffold subtree is still stable, and defer the state change to a post-frame callback so the snack bar's enter-animation can finish before the WebView reload and the provider-driven rebuild run.
In `lib/ui/screens/email_detail_screen.dart`, replace the body of `OutlinedButton.icon.onPressed` at lines 231-261 with:
```dart
onPressed: () {
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
),
),
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _loadRemoteImages = true);
});
},
```
Apply the same reordering to `lib/ui/screens/thread_detail_screen.dart:206-236`.
The key changes:
- `showSnackBar` runs first, on the still-stable scaffold subtree.
- `setState` (which triggers WebView swap-in and subsequent rebuilds) is deferred to a post-frame callback.
- When `senderEmail == null` (no trusted-sender to register, so no snack bar), the post-frame callback still flips `_loadRemoteImages` to true — preserving existing behavior of the button working even for unknown senders.
### Tests
Add a widget test in `test/widget/email_detail_screen_test.dart` that:
1. Pumps an `EmailDetailScreen` with an HTML body and a non-empty `From` header.
2. Taps the "Load remote images" button.
3. Verifies the snack bar with text "Images will be loaded automatically for this sender." appears.
4. Calls `tester.pump(const Duration(seconds: 4))` (or uses `tester.pumpAndSettle` after a 3.5s pump).
5. Verifies the snack bar is gone (`expect(find.byType(SnackBar), findsNothing)`).
6. Verifies `_loadRemoteImages` did flip, by checking that the "Load remote images" button is no longer present.
Add an analogous test in `test/widget/thread_detail_screen_test.dart` (or wherever thread tests live; create the file if it does not exist yet — use the email_detail test as a template).
### Out of scope
- The "First update agent loop, fix search bug" line in the issue body is two unrelated todo notes the reporter jotted down (the search bug is tracked separately). This plan does not address them.
- Other `showSnackBar` call sites in `email_detail_screen.dart` (download success/failure, copy-to-clipboard, raw-email errors, etc.) are not affected by the same rebuild pattern and stay unchanged.
### Verification checklist
- [ ] `dart test` (or the project's `task test` equivalent) passes, including the two new widget tests.
- [ ] Manual: open a single mail in `EmailDetailScreen` with HTML body from a sender not yet trusted; tap "Load remote images"; verify snack bar appears, images load, and snack bar disappears after ~3 seconds.
- [ ] Manual: tap "View" on the snack bar before it dismisses; verify it navigates to `/accounts/trusted-senders` and that the snack bar is dismissed by the navigation as expected.
- [ ] Manual: repeat in `ThreadDetailScreen`.
+1 -1
View File
@@ -544,7 +544,7 @@ tasks:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
+1 -1
View File
@@ -896,7 +896,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
}
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
func (m *Ci) UploadToPlayStore(
ctx context.Context,
aab *dagger.File,
Generated
-82
View File
@@ -1,82 +0,0 @@
{
"nodes": {
"dagger": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778107833,
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
"owner": "dagger",
"repo": "nix",
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
"type": "github"
},
"original": {
"owner": "dagger",
"repo": "nix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"dagger": "dagger",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-166
View File
@@ -1,166 +0,0 @@
{
description = "SharedInbox IMAP/SMTP Flutter client";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
dagger.url = "github:dagger/nix";
dagger.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, flake-utils, dagger }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# All Linux desktop runtime libraries needed by flutter build linux and
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
linuxDesktopLibs = with pkgs; [
gtk3
libsecret
fontconfig
libepoxy
mesa
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
at-spi2-core
glib
pango
cairo
gdk-pixbuf
harfbuzz
# Dagger remote setup dependencies
stunnel
netcat
];
fgj = pkgs.stdenv.mkDerivation {
pname = "fgj";
version = "0.4.0";
src = pkgs.fetchurl {
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
};
dontUnpack = true;
installPhase = ''
mkdir -p $out/bin
cp $src $out/bin/fgj
chmod +x $out/bin/fgj
'';
};
# The dagger/nix flake's Nix wrapper is a broken self-exec loop, so we
# fetch the CLI binary directly. Keep this version in lockstep with
# ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
# scripts/check_dagger_versions.sh enforces this.
daggerCli = pkgs.stdenv.mkDerivation {
pname = "dagger";
version = "0.20.8";
src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
};
sourceRoot = ".";
installPhase = ''
mkdir -p $out/bin
cp dagger $out/bin/dagger
chmod +x $out/bin/dagger
'';
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Dagger CLI
daggerCli
# Go compiler — for Dagger development
go
# Java JDK — required by Gradle for Android builds
# Task runner
go-task
# Flutter version manager — needed for host builds (task build-linux, task run)
fvm
# Git hooks
pre-commit
# Linux desktop build + runtime dependencies (flutter build linux / task run)
] ++ linuxDesktopLibs ++ (with pkgs; [
pkg-config
clang
cmake
ninja
# Local IMAP/SMTP dev server for integration tests
stalwart-mail
# Headless display for UI integration tests
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
# Coverage merging (flutter test --merge-coverage requires lcov)
lcov
# Website
hugo
# Utilities
git
curl
jq
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
librsvg # rsvg-convert — SVG→PNG for generate-icons task
]);
shellHook = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
# Expose dev headers to cmake's FindPkgConfig.
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
# deps are not followed automatically, so link them explicitly.
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
# Make nix-built runtime libs visible to the dynamic linker so the
# Flutter Linux bundle and integration-ui tests can run.
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
echo "SharedInbox Flutter dev environment ready."
echo " Analyze : task analyze"
echo " Unit tests : task test"
echo " Integration : task integration"
echo " All checks : task check"
echo " Run (Linux) : task run"
echo " Start Stalwart : stalwart-dev/start"
'';
};
}
);
}
+2 -8
View File
@@ -13,11 +13,6 @@ ROOT=$(git rev-parse --show-toplevel)
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
| sed -E 's/.*"v?([^"]+)"$/\1/')
# flake.nix — the dagger021 derivation's CLI download URL.
flake_nix=$(grep -oE 'dagger_v[0-9]+\.[0-9]+\.[0-9]+_linux' "$ROOT/flake.nix" \
| head -n1 \
| sed -E 's/dagger_v([0-9.]+)_linux/\1/')
# .forgejo/Dockerfile — DAGGER_VERSION env on the install line.
dockerfile=$(grep -oE 'DAGGER_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/.forgejo/Dockerfile" \
| head -n1 \
@@ -29,11 +24,10 @@ dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
| sed -E 's@.*/v@@')
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
printf 'flake.nix dagger021 = %s\n' "$flake_nix"
printf '.forgejo/Dockerf. DAGGER_VERSION= %s\n' "$dockerfile"
printf 'DAGGER.md engine tag = v%s\n' "$dagger_md"
for v in "$flake_nix" "$dockerfile" "$dagger_md"; do
for v in "$dockerfile" "$dagger_md"; do
if [ -z "$v" ]; then
echo "ERROR: failed to parse a Dagger version reference." >&2
exit 1
@@ -41,7 +35,7 @@ for v in "$flake_nix" "$dockerfile" "$dagger_md"; do
if [ "$v" != "$dagger_json" ]; then
echo "" >&2
echo "ERROR: Dagger versions are out of sync." >&2
echo " Align ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
echo " Align ci/dagger.json, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
exit 1
fi
done
+16 -9
View File
@@ -1,5 +1,11 @@
#!/usr/bin/env python3
"""Upload an Android App Bundle to the Google Play Store internal track."""
"""Upload an Android App Bundle to the Google Play Store.
The bundle is published to every track in ``TRACKS`` within a single Play edit,
so internal testing and closed testing share the same version code. ``alpha``
is what the Play Console labels "Closed testing"; publishing there removes the
need to manually drag-and-drop the AAB into the closed-testing release form.
"""
import json
import os
@@ -11,7 +17,7 @@ from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
TRACKS = ("internal", "alpha")
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
@@ -94,19 +100,20 @@ def main():
version_code = bundle["versionCode"]
print(f"Uploaded AAB, version code: {version_code}")
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
for track in TRACKS:
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
timeout=30,
)
commit_resp.raise_for_status()
print(f"Deployed version {version_code} to {TRACK} track")
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
if __name__ == "__main__":
+24
View File
@@ -95,6 +95,30 @@ class TestMainHappyPath(unittest.TestCase):
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
def test_updates_all_configured_tracks(self):
session = self._run_main()
track_urls = [c[0][0] for c in session.put.call_args_list]
self.assertEqual(len(track_urls), len(deploy_playstore.TRACKS))
for track in deploy_playstore.TRACKS:
self.assertTrue(
any(url.endswith(f"/tracks/{track}") for url in track_urls),
f"no PUT to /tracks/{track} (saw {track_urls})",
)
def test_commits_after_all_track_updates(self):
session = self._run_main()
# All PUTs are track updates; commit is the second POST after the
# initial edit-create. Verify PUTs precede the commit by checking
# mock_calls order across both methods.
method_order = [c[0] for c in session.method_calls]
commit_idx = next(
i for i, m in enumerate(method_order)
if m == "post" and ":commit" in session.method_calls[i][1][0]
)
put_indices = [i for i, m in enumerate(method_order) if m == "put"]
self.assertEqual(len(put_indices), len(deploy_playstore.TRACKS))
self.assertTrue(all(i < commit_idx for i in put_indices))
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):