From 0b12d2ed2d49b2c1640b6cf4dfd1f9d712f0946f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 29 Jul 2026 11:21:02 +0700 Subject: [PATCH] Cover pause and resume in the e2e run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idempotency assertion compared counter deltas between the two runs, but per-run counters are reset at the start of every run, so the second run's row already showed that run alone — the delta was negative and the script failed before reaching anything else. With that fixed, a second account of 3000 messages exercises the new stop path end to end: pause mid-folder, assert the task and the account settle into paused, resume, and assert the resumed run covers every message while skipping the ones the paused stretch had already copied. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/e2e.sh | 103 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 143b1c6..c493525 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -158,7 +158,8 @@ wait_test_ok() { wait_test_ok wait_run_done() { - for ((i = 1; i <= 60; i++)); do + # Generous: the resume scenario re-scans and copies thousands of messages. + for ((i = 1; i <= 600; i++)); do local status status=$(api GET "/api/tasks/${TASK_ID}" | jq -r '.task.status') if [[ "$status" == "done" ]]; then @@ -185,16 +186,100 @@ log "POST /run (second run, expect idempotency)" api POST "/api/tasks/${TASK_ID}/run" >/dev/null wait_run_done +# Counters are reset at the start of every run, so run 2's row shows run 2 +# alone: it must copy nothing and skip what run 1 already migrated. RES2=$(api GET "/api/tasks/${TASK_ID}") -RUN2_COPIED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].copied') -RUN2_SKIPPED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].skipped') +RUN2_COPIED=$(echo "$RES2" | jq -r '.accounts[0].copied') +RUN2_SKIPPED=$(echo "$RES2" | jq -r '.accounts[0].skipped') RUN2_ERRORS=$(echo "$RES2" | jq -r '.accounts[0].errors') -RUN2_COPIED_DELTA=$((RUN2_COPIED_TOTAL - RUN1_COPIED)) -RUN2_SKIPPED_DELTA=$((RUN2_SKIPPED_TOTAL - RUN1_SKIPPED)) -log "run 2: copied_delta=$RUN2_COPIED_DELTA skipped_delta=$RUN2_SKIPPED_DELTA errors=$RUN2_ERRORS" +log "run 2: copied=$RUN2_COPIED skipped=$RUN2_SKIPPED errors=$RUN2_ERRORS" [[ "$RUN2_ERRORS" == "0" ]] || fail "run 2 had errors" -[[ "$RUN2_COPIED_DELTA" -eq 0 ]] || fail "run 2 copied $RUN2_COPIED_DELTA new messages (expected 0, not idempotent)" -[[ "$RUN2_SKIPPED_DELTA" -gt 0 ]] || fail "run 2 skipped delta is $RUN2_SKIPPED_DELTA (expected >0)" +[[ "$RUN2_COPIED" -eq 0 ]] || fail "run 2 copied $RUN2_COPIED new messages (expected 0, not idempotent)" +[[ "$RUN2_SKIPPED" -eq "$RUN1_COPIED" ]] || + fail "run 2 skipped $RUN2_SKIPPED of the $RUN1_COPIED messages run 1 copied" -log "PASS: run1 copied=$RUN1_COPIED skipped=$RUN1_SKIPPED; run2 copied=$RUN2_COPIED_DELTA skipped=$RUN2_SKIPPED_DELTA (idempotent)" +log "run1 copied=$RUN1_COPIED skipped=$RUN1_SKIPPED; run2 copied=$RUN2_COPIED skipped=$RUN2_SKIPPED (idempotent)" + +# --------------------------------------------------------------------------- +# Pause / resume: a second account with enough messages that the run is still +# in flight when the pause lands. Pausing must leave the account resumable, and +# resuming must finish it without re-copying what the first stretch already did. +# --------------------------------------------------------------------------- +SRC_USER2="src2@example.com" +DST_USER2="dst2@example.com" +# Large enough that the copy is still in flight when the pause lands — greenmail +# on a local socket copies well over a thousand small messages per second. +SEED_COUNT=3000 + +log "seeding ${SEED_COUNT} messages into ${SRC_USER2} INBOX (pause/resume scenario)" +python3 "$SEED_PY" 127.0.0.1 3143 "$SRC_USER2" "$MAIL_PASS" "$SEED_COUNT" + +log "adding second account (src2 -> dst2)" +ACCOUNT2_ID=$(api POST "/api/tasks/${TASK_ID}/accounts" \ + "{\"src_login\":\"${SRC_USER2}\",\"src_pass\":\"${MAIL_PASS}\",\"dst_login\":\"${DST_USER2}\",\"dst_pass\":\"${MAIL_PASS}\"}" | jq -r .id) +[[ "$ACCOUNT2_ID" =~ ^[0-9]+$ ]] || fail "bad second account id: $ACCOUNT2_ID" +log "account2_id=$ACCOUNT2_ID" + +log "POST /test (both accounts)" +api POST "/api/tasks/${TASK_ID}/test" >/dev/null +for ((i = 1; i <= 30; i++)); do + BOTH_OK=$(api GET "/api/tasks/${TASK_ID}" | + jq -r '[.accounts[] | select(.test_src_status=="ok" and .test_dst_status=="ok")] | length') + [[ "$BOTH_OK" == "2" ]] && break + sleep 1 +done +[[ "$BOTH_OK" == "2" ]] || fail "second account did not pass connection tests (ok count=$BOTH_OK)" + +# Account view for account2, by id. +acct2() { api GET "/api/tasks/${TASK_ID}" | jq -r ".accounts[] | select(.id==${ACCOUNT2_ID}) | $1"; } + +log "POST /run (account2 only)" +api POST "/api/tasks/${TASK_ID}/run" "{\"account_ids\":[${ACCOUNT2_ID}]}" >/dev/null + +# Per-account counters are only written to the DB when a folder completes, so +# "copied so far" is invisible here — wait for the account to go running, give +# the copy a few seconds of real work, then pause mid-folder. +log "waiting for account2 to start running" +for ((i = 1; i <= 120; i++)); do + [[ "$(acct2 .status)" == "running" ]] && break + sleep 0.5 +done +[[ "$(acct2 .status)" == "running" ]] || fail "account2 never reached running" + +log "letting it copy for a few seconds, then pausing mid-folder" +sleep 5 +curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X POST "$BASE/api/tasks/${TASK_ID}/pause" >/dev/null || + fail "pause rejected — the run finished before the pause landed, seed more messages" + +log "waiting for the task to settle into paused" +for ((i = 1; i <= 60; i++)); do + TASK_STATUS=$(api GET "/api/tasks/${TASK_ID}" | jq -r '.task.status') + [[ "$TASK_STATUS" == "paused" ]] && break + sleep 1 +done +[[ "$TASK_STATUS" == "paused" ]] || fail "task status=$TASK_STATUS after pause (expected paused)" + +ACC2_STATUS=$(acct2 .status) +[[ "$ACC2_STATUS" == "paused" ]] || fail "account2 status=$ACC2_STATUS after pause (expected paused)" +log "paused (folder-level counters at copied=$(acct2 .copied) of $SEED_COUNT)" + +log "POST /resume" +api POST "/api/tasks/${TASK_ID}/resume" >/dev/null +wait_run_done + +RESUMED_COPIED=$(acct2 .copied) +RESUMED_SKIPPED=$(acct2 .skipped) +RESUMED_ERRORS=$(acct2 .errors) +RESUMED_TOTAL=$((RESUMED_COPIED + RESUMED_SKIPPED)) +log "after resume: copied=$RESUMED_COPIED skipped=$RESUMED_SKIPPED errors=$RESUMED_ERRORS" +[[ "$RESUMED_ERRORS" == "0" ]] || fail "resumed run had errors" +# Counters reset per run, so the resumed run alone must account for every +# message: the ones it copied now plus the ones the paused stretch already did. +[[ "$RESUMED_TOTAL" -eq "$SEED_COUNT" ]] || fail "resumed run covered $RESUMED_TOTAL of $SEED_COUNT messages" +# Non-zero skipped is the proof that the paused stretch's work survived: those +# messages are in the migration journal, so the resume did not re-copy them. +[[ "$RESUMED_SKIPPED" -gt 0 ]] || + fail "resumed run skipped nothing — the paused stretch's progress was lost" + +log "PASS: idempotent re-run; pause was resumable, resume re-copied $RESUMED_COPIED and skipped $RESUMED_SKIPPED of $SEED_COUNT"