Write professional, persuasive complaint letters to US airlines emphasizing loyalty status, DOT regulations, and airline commitments.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""Outcome-focused tests for the credits-tracker / complaints-bank storage bootstrap.
Asserts observable behavior (exit codes, on-disk store shape, stdout) rather than
internals. Each test runs the real CLI in a subprocess against a throwaway HOME, so the
store always resolves to ~/.claude/<store> exactly as it does in production. Deterministic:
all inputs are fixed and built programmatically; no randomness, no network, no shared state.
Run directly: python3 test_trackers.py (exit 0 = all passed, 1 = a failure)
Also discoverable by pytest (test_* functions).
"""
import json
import os
import atexit
import shutil
import subprocess
import sys
import tempfile
# Track every temp dir we create and remove them on exit, so repeated local/CI runs
# don't leak directories under the system temp dir.
_TMPDIRS = []
@atexit.register
def _cleanup_tmpdirs():
for d in _TMPDIRS:
shutil.rmtree(d, ignore_errors=True)
def _mktemp(prefix):
d = tempfile.mkdtemp(prefix=prefix)
_TMPDIRS.append(d)
return d
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPTS = os.path.normpath(os.path.join(HERE, "..", "scripts"))
CREDITS = os.path.join(SCRIPTS, "credits-tracker.py")
BANK = os.path.join(SCRIPTS, "complaints-bank.py")
def _load_tracker():
"""Import credits-tracker.py by path — its filename has a hyphen."""
import importlib.util
spec = importlib.util.spec_from_file_location("credits_tracker", CREDITS)
assert spec is not None and spec.loader is not None, f"cannot load {CREDITS}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Read the version the script actually ships rather than hardcoding it. A suite that
# pins a literal has to be rewritten on every bump, which is how a migration lands
# with its own tests asserting the version it replaced.
CURRENT_SCHEMA = _load_tracker().SCHEMA_VERSION
PRIOR_SCHEMA = CURRENT_SCHEMA - 1
def vline(version):
return f"- **Schema version**: {version}"
# (script, store dir under ~/.claude, a read-only command that triggers require_initialized)
STORES = [
(CREDITS, "travel-credits", ["summary"]),
(BANK, "complaint-bank", ["list"]),
]
# Frozen reference date for every date-sensitive case. testing-standards Determinism
# bans a value that depends on the run-time clock, and a fixture pinning a future date
# only moves the deadline — it starts failing on its own once the real clock passes it.
# credits-tracker.py reads TODAY_ENV instead of the wall clock, so the fixtures below
# can use fixed PAST dates and still exercise "expires soon".
FROZEN_TODAY = "2026-03-01"
def run(script, args, home, cwd=None, stdin_text=None,
today: "str | None" = FROZEN_TODAY):
env = dict(os.environ, HOME=home)
if today is None:
env.pop("CREDITS_TRACKER_TODAY", None) # unset: the production path
else:
env["CREDITS_TRACKER_TODAY"] = today # set, including to "" on purpose
return subprocess.run(
[sys.executable, script, *args],
env=env, cwd=cwd, capture_output=True, text=True, input=stdin_text,
)
def store_path(home, sub):
return os.path.join(home, ".claude", sub)
def fresh_home():
return _mktemp(prefix="ffa-test-home-")
# ── require_initialized ───────────────────────────────────────────────────────
def test_read_fails_when_uninitialized():
for script, sub, read_cmd in STORES:
home = fresh_home()
r = run(script, read_cmd, home)
assert r.returncode == 2, f"{script}: expected exit 2, got {r.returncode}\n{r.stderr}"
assert "not initialized" in r.stderr.lower(), f"{script}: {r.stderr}"
def test_regular_file_at_store_path_is_rejected():
for script, sub, read_cmd in STORES:
home = fresh_home()
os.makedirs(os.path.join(home, ".claude"))
open(store_path(home, sub), "w").close() # a plain file where the store should be
r = run(script, read_cmd, home)
assert r.returncode == 2, f"{script}: expected exit 2, got {r.returncode}"
assert "not a directory" in r.stderr.lower(), f"{script}: {r.stderr}"
# ── init --default ────────────────────────────────────────────────────────────
def test_init_default_creates_usable_store():
for script, sub, read_cmd in STORES:
home = fresh_home()
r = run(script, ["init", "--default"], home)
assert r.returncode == 0, f"{script}: init failed\n{r.stderr}"
assert os.path.isdir(store_path(home, sub)), f"{script}: store dir missing"
# read command now works
r2 = run(script, read_cmd, home)
assert r2.returncode == 0, f"{script}: read after init failed\n{r2.stderr}"
def test_init_default_refuses_dangling_symlink():
# A dangling symlink usually means the real (cloud) store is unmounted — init must
# NOT clobber it into a fresh empty store; it must fail with recovery guidance.
for script, sub, read_cmd in STORES:
home = fresh_home()
os.makedirs(os.path.join(home, ".claude"))
os.symlink(os.path.join(home, "gone"), store_path(home, sub)) # dangling
r = run(script, ["init", "--default"], home)
assert r.returncode == 2, f"{script}: init should refuse a dangling symlink\n{r.stderr}"
assert "symlink" in r.stderr.lower() and ("re-link" in r.stderr.lower() or "remount" in r.stderr.lower()), \
f"{script}: expected recovery guidance, got: {r.stderr}"
assert os.path.islink(store_path(home, sub)), f"{script}: dangling symlink must be preserved, not clobbered"
# ── mutually exclusive flags ──────────────────────────────────────────────────
def test_init_default_and_path_are_mutually_exclusive():
for script, sub, read_cmd in STORES:
home = fresh_home()
r = run(script, ["init", "--default", "--path", os.path.join(home, "x")], home)
assert r.returncode != 0, f"{script}: --default --path together should be rejected"
assert "not allowed with" in r.stderr.lower() or "mutually exclusive" in r.stderr.lower(), \
f"{script}: expected argparse mutual-exclusion error, got: {r.stderr}"
# ── relative custom path → absolute symlink ───────────────────────────────────
def test_init_path_relative_becomes_absolute_symlink():
for script, sub, read_cmd in STORES:
home = fresh_home()
workdir = _mktemp(prefix="ffa-test-cwd-")
r = run(script, ["init", "--path", "relsub"], home, cwd=workdir)
assert r.returncode == 0, f"{script}: init --path relsub failed\n{r.stderr}"
link = store_path(home, sub)
assert os.path.islink(link), f"{script}: store should be a symlink"
target = os.readlink(link)
assert os.path.isabs(target), f"{script}: symlink target must be absolute, got {target!r}"
assert os.path.realpath(target) == os.path.realpath(os.path.join(workdir, "relsub")), \
f"{script}: symlink should resolve to the cwd-relative dir, got {target!r}"
# ── link ──────────────────────────────────────────────────────────────────────
def test_link_preserves_existing_store_and_is_idempotent():
script, sub, read_cmd = STORES[0] # credits-tracker
home = fresh_home()
cloud = _mktemp(prefix="ffa-test-cloud-")
# seed a populated store at the cloud location, then unlink the default
assert run(script, ["init", "--path", cloud], home).returncode == 0
assert run(script, ["add", "--type", "ECREDIT", "--desc", "Seed", "--value", "200",
"--passenger", "Baruch", "--airline", "DL"], home).returncode == 0
os.unlink(store_path(home, sub)) # simulate a fresh machine: data in cloud, not linked
r = run(script, ["link", "--path", cloud], home)
assert r.returncode == 0, f"link failed\n{r.stderr}"
listing = run(script, ["list"], home)
assert "Seed" in listing.stdout, f"linked store lost its data:\n{listing.stdout}"
# re-link is a no-op, not an error
again = run(script, ["link", "--path", cloud], home)
assert again.returncode == 0 and "already linked" in again.stdout.lower(), \
f"re-link should be idempotent: rc={again.returncode}\n{again.stdout}{again.stderr}"
def test_link_empty_path_is_rejected():
# Empty AND whitespace-only --path must be refused: abspath('') / abspath(' ') would
# otherwise resolve against the cwd and link the store somewhere unintended.
for script, sub, read_cmd in STORES:
for bad in ["", " "]:
home = fresh_home()
r = run(script, ["link", "--path", bad], home)
assert r.returncode == 1, f"{script}: link path {bad!r} should exit 1, got {r.returncode}"
assert "no path" in r.stderr.lower(), f"{script}: {bad!r}: {r.stderr}"
assert not os.path.lexists(store_path(home, sub)), \
f"{script}: link path {bad!r} must not create a store"
def test_link_to_dir_without_inventory_is_rejected():
# `link` attaches to an EXISTING store; pointing it at a dir with no inventory.md /
# complaints.md must be refused, not silently bootstrapped into a second diverging store.
for script, sub, read_cmd in STORES:
home = fresh_home()
empty = _mktemp(prefix="ffa-test-empty-")
r = run(script, ["link", "--path", empty], home)
assert r.returncode == 1, f"{script}: link to dir without marker should exit 1, got {r.returncode}\n{r.stderr}"
assert "does not create one" in r.stderr.lower() or "attaches to an existing" in r.stderr.lower(), \
f"{script}: expected refuse-to-bootstrap guidance, got: {r.stderr}"
assert not os.path.lexists(store_path(home, sub)), \
f"{script}: link must not create a store when refusing"
# ── status subcommand + regular-file init guard ───────────────────────────────
def test_status_reports_missing_ready_invalid():
for script, sub, read_cmd in STORES:
home = fresh_home()
# missing
r = run(script, ["status"], home)
assert r.returncode == 3 and "missing" in r.stdout.lower(), \
f"{script}: expected missing/exit3, got {r.returncode}: {r.stdout}{r.stderr}"
# ready after init — stdout is the exact bare token, resolved path goes to stderr
assert run(script, ["init", "--default"], home).returncode == 0
r = run(script, ["status"], home)
assert r.returncode == 0 and r.stdout.strip() == "ready", \
f"{script}: expected bare 'ready' token/exit0, got {r.returncode}: {r.stdout!r}"
# invalid: a plain file where the store should be
home2 = fresh_home()
os.makedirs(os.path.join(home2, ".claude"))
open(store_path(home2, sub), "w").close()
r = run(script, ["status"], home2)
assert r.returncode == 4 and "invalid" in r.stdout.lower(), \
f"{script}: expected invalid/exit4, got {r.returncode}: {r.stdout}"
def test_init_default_refuses_regular_file_without_crashing():
# A plain file at the store path must produce an actionable error, not an uncaught
# FileExistsError from os.makedirs.
for script, sub, read_cmd in STORES:
home = fresh_home()
os.makedirs(os.path.join(home, ".claude"))
open(store_path(home, sub), "w").close()
r = run(script, ["init", "--default"], home)
assert r.returncode == 2, f"{script}: expected exit 2, got {r.returncode}\n{r.stdout}{r.stderr}"
assert "not a directory" in r.stderr.lower(), f"{script}: {r.stderr}"
assert "traceback" not in r.stderr.lower(), f"{script}: crashed instead of clean error:\n{r.stderr}"
def test_init_empty_or_whitespace_path_is_rejected():
# `init --path ""` (and whitespace-only) must reach the self-error-handled diagnostic, not
# fall through to the interactive branch: cmd_init dispatches on presence, not truthiness.
for script, sub, read_cmd in STORES:
for bad in ["", " "]:
home = fresh_home()
r = run(script, ["init", "--path", bad], home, stdin_text="")
assert r.returncode == 1, \
f"{script}: init --path {bad!r} should exit 1, got {r.returncode}\n{r.stdout}{r.stderr}"
assert "no path" in r.stderr.lower(), f"{script}: {bad!r}: {r.stderr}"
assert not os.path.lexists(store_path(home, sub)), \
f"{script}: init --path {bad!r} must not create a store"
assert "traceback" not in r.stderr.lower(), f"{script}: crashed:\n{r.stderr}"
def test_init_path_refuses_existing_file():
# A plain file at the --path target would make os.makedirs(exist_ok=True) raise an
# opaque FileExistsError — init must refuse with an actionable message, not crash.
for script, sub, read_cmd in STORES:
home = fresh_home()
target_parent = _mktemp(prefix="ffa-test-target-")
filepath = os.path.join(target_parent, "afile")
open(filepath, "w").close()
r = run(script, ["init", "--path", filepath], home)
assert r.returncode == 1, f"{script}: init --path <file> should exit 1, got {r.returncode}\n{r.stderr}"
assert "not a usable directory" in r.stderr.lower(), f"{script}: {r.stderr}"
assert not os.path.lexists(store_path(home, sub)), f"{script}: no store should be created"
assert "traceback" not in r.stderr.lower(), f"{script}: crashed instead of clean error:\n{r.stderr}"
def test_init_path_refuses_dangling_symlink_target():
# A dangling symlink at the --path target: exists() is False but islink() is True, so
# os.makedirs would raise FileExistsError. init must refuse, not crash.
for script, sub, read_cmd in STORES:
home = fresh_home()
target_parent = _mktemp(prefix="ffa-test-target-")
dangling = os.path.join(target_parent, "dangling")
os.symlink(os.path.join(target_parent, "missing"), dangling)
r = run(script, ["init", "--path", dangling], home)
assert r.returncode == 1, f"{script}: init --path <dangling> should exit 1, got {r.returncode}\n{r.stderr}"
assert "not a usable directory" in r.stderr.lower(), f"{script}: {r.stderr}"
assert not os.path.lexists(store_path(home, sub)), f"{script}: no store should be created"
assert "traceback" not in r.stderr.lower(), f"{script}: crashed:\n{r.stderr}"
def test_interactive_init_over_regular_file_refuses():
# Interactive `init` (no --default/--path) must NOT clobber a plain file at the store
# path — it routes through the refuse-unusable contract, not os.unlink/shutil.rmtree.
for script, sub, read_cmd in STORES:
home = fresh_home()
os.makedirs(os.path.join(home, ".claude"))
open(store_path(home, sub), "w").close() # plain file where the store should be
r = run(script, ["init"], home, stdin_text="y\n")
assert r.returncode == 2, \
f"{script}: interactive init over a file should exit 2, got {r.returncode}\n{r.stdout}{r.stderr}"
assert "not a directory" in r.stderr.lower(), f"{script}: {r.stderr}"
assert os.path.isfile(store_path(home, sub)), f"{script}: the plain file must be preserved, not clobbered"
assert "traceback" not in r.stderr.lower(), f"{script}: crashed:\n{r.stderr}"
def test_status_distinguishes_symlink_to_file_from_dangling():
# A symlink to an existing non-directory is NOT dangling — status must say so, and only
# call a symlink "dangling" when its target is actually missing.
for script, sub, read_cmd in STORES:
home = fresh_home()
os.makedirs(os.path.join(home, ".claude"))
afile = os.path.join(home, "afile")
open(afile, "w").close()
os.symlink(afile, store_path(home, sub)) # symlink → existing file
r = run(script, ["status"], home)
assert r.returncode == 4, f"{script}: symlink-to-file status should exit 4, got {r.returncode}\n{r.stdout}"
assert "invalid" in r.stdout.lower() and "not a directory" in r.stdout.lower(), \
f"{script}: expected 'not a directory', got: {r.stdout}"
assert "dangling" not in r.stdout.lower(), \
f"{script}: a symlink to an existing file must NOT be reported as dangling: {r.stdout}"
# contrast: a genuinely dangling symlink IS reported as dangling
home2 = fresh_home()
os.makedirs(os.path.join(home2, ".claude"))
os.symlink(os.path.join(home2, "gone"), store_path(home2, sub))
r2 = run(script, ["status"], home2)
assert r2.returncode == 4 and "dangling" in r2.stdout.lower(), \
f"{script}: expected dangling, got: {r2.stdout}"
# ── hotel brand dimension (credits-tracker only) ──────────────────────────────
def _seeded_home_with_hotel_and_airline_credits():
"""Init a store and seed one hotel-brand voucher + one airline eCredit. Returns home."""
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Comp 2-night stay",
"--value", "2 nights", "--expiry", "2026-03-31",
"--passenger", "Baruch", "--brand", "Hilton"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Canceled BNA-JFK",
"--value", "347.20", "--expiry", "2026-06-15",
"--passenger", "Baruch", "--airline", "DL"], home).returncode == 0
return home
def test_add_brand_is_stored_and_shown_in_list():
home = _seeded_home_with_hotel_and_airline_credits()
r = run(CREDITS, ["list"], home)
assert r.returncode == 0, r.stderr
assert "Brand" in r.stdout, f"list should have a Brand column:\n{r.stdout}"
assert "HILTON" in r.stdout, f"the Hilton voucher's normalized brand should show:\n{r.stdout}"
def test_list_brand_filter_collapses_subbrands_to_chain():
# A credit tagged with a sub-brand (Conrad) must be found by filtering on the chain (Hilton).
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Conrad stay", "--value",
"1 night", "--passenger", "Baruch", "--brand", "Conrad"], home).returncode == 0
hit = run(CREDITS, ["list", "--brand", "Hilton"], home)
assert hit.returncode == 0 and "Conrad stay" in hit.stdout, \
f"--brand Hilton should match a Conrad-tagged credit:\n{hit.stdout}"
miss = run(CREDITS, ["list", "--brand", "Marriott"], home)
assert miss.returncode == 0, f"list --brand Marriott should succeed, got {miss.returncode}\n{miss.stderr}"
assert "Conrad stay" not in miss.stdout, f"--brand Marriott must not match a Hilton credit:\n{miss.stdout}"
def test_check_surfaces_hotel_credit_for_hotel_scenario():
# The core bug: a hotel scenario must surface a brand-tagged credit (airline-only matching
# never could). The use-it-or-lose-it prompt has to fire for hotel stays.
home = _seeded_home_with_hotel_and_airline_credits()
r = run(CREDITS, ["check", "--scenario", "Hilton London, 3 nights"], home)
assert r.returncode == 0, r.stderr
assert "Comp 2-night stay" in r.stdout, f"hotel voucher should surface:\n{r.stdout}"
assert "HILTON" in r.stdout, f"detected brand should be reported:\n{r.stdout}"
assert "Canceled BNA-JFK" not in r.stdout, f"airline credit must NOT surface for a hotel scenario:\n{r.stdout}"
def test_mixed_issuer_credit_surfaces_on_each_dimension():
# Regression (#15): a credit carrying BOTH --airline and --brand must match each issuer
# dimension independently — it surfaces for an airline scenario on its airline AND for a
# hotel scenario on its brand. The earlier brand gate made the two mutually exclusive, so
# a both-tagged credit vanished from airline scenarios.
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Co-branded stay credit",
"--value", "250", "--passenger", "Baruch",
"--airline", "DL", "--brand", "Hilton"], home).returncode == 0
airline = run(CREDITS, ["check", "--scenario", "Delta business JFK-CDG"], home)
assert airline.returncode == 0 and "Co-branded stay credit" in airline.stdout, \
f"a DL+Hilton credit must surface for an airline scenario on its airline dimension:\n{airline.stdout}"
hotel = run(CREDITS, ["check", "--scenario", "Hilton London, 2 nights"], home)
assert hotel.returncode == 0 and "Co-branded stay credit" in hotel.stdout, \
f"the same credit must surface for a hotel scenario on its brand dimension:\n{hotel.stdout}"
def test_check_brand_alias_matches_parent_chain():
# A sub-brand named in the scenario (Conrad) must surface a credit tagged with the chain.
home = _seeded_home_with_hotel_and_airline_credits()
r = run(CREDITS, ["check", "--scenario", "Conrad Tokyo, 2 nights"], home)
assert r.returncode == 0 and "Comp 2-night stay" in r.stdout, \
f"a Conrad scenario should surface the HILTON-tagged voucher:\n{r.stdout}"
def test_check_hotel_credit_does_not_bleed_into_airline_scenario():
# An airline scenario must surface only the airline credit — the hotel voucher must not
# appear, and must not trigger the legacy "airline not specified" note.
home = _seeded_home_with_hotel_and_airline_credits()
r = run(CREDITS, ["check", "--scenario", "Delta business JFK-CDG"], home)
assert r.returncode == 0, r.stderr
assert "Canceled BNA-JFK" in r.stdout, f"airline eCredit should surface:\n{r.stdout}"
assert "Comp 2-night stay" not in r.stdout, f"hotel voucher must not bleed into airline scenario:\n{r.stdout}"
assert "airline not specified" not in r.stdout.lower(), \
f"a brand-tagged credit must not get the 'airline not specified' note:\n{r.stdout}"
def test_ambiguous_words_do_not_false_match_hotel_brands():
# The whole point of dropping bare aliases (honors, choice, courtyard, …): ordinary
# airline/travel prose containing those words must NOT surface a hotel credit. If any of
# these regress to bare aliases, a Hilton/Choice/Marriott credit bleeds into the wrong
# scenario.
home = _seeded_home_with_hotel_and_airline_credits() # has a HILTON voucher
bleed_scenarios = [
"Delta honors the upgrade request", # 'honors' must not mean Hilton
"Economy was our only choice", # 'choice' must not mean Choice Hotels
"United courtyard-view lounge", # 'courtyard' must not mean Marriott
"Renaissance-era art tour, AA flight", # 'renaissance' must not mean Marriott
]
for sc in bleed_scenarios:
r = run(CREDITS, ["check", "--scenario", sc], home)
assert r.returncode == 0, f"{sc!r}: {r.stderr}"
assert "Comp 2-night stay" not in r.stdout, \
f"{sc!r} must NOT surface the Hilton voucher (ambiguous bare-word match):\n{r.stdout}"
assert "Hotel brands detected" not in r.stdout, \
f"{sc!r} must not detect any hotel brand:\n{r.stdout}"
def test_brand_tagged_non_voucher_credit_no_cross_bleed():
# A brand-tagged COMPANION must NOT surface in an airline scenario, even one whose
# words ("domestic", "companion") trip the airline-era companion heuristic. Brand is
# the single gate across the whole --brand surface, not just VOUCHER.
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "COMPANION", "--desc", "Hotel stay certificate",
"--value", "1 certificate", "--passenger", "Baruch",
"--brand", "Hilton Honors"], home).returncode == 0
airline = run(CREDITS, ["check", "--scenario", "Delta round-trip domestic companion fare"], home)
assert airline.returncode == 0, airline.stderr
assert "Hotel stay certificate" not in airline.stdout, \
f"a brand-tagged COMPANION must not bleed into an airline scenario:\n{airline.stdout}"
assert "Companion certificate may apply" not in airline.stdout, \
f"the airline-era companion heuristic must not fire for a hotel credit:\n{airline.stdout}"
# ...but it DOES surface for the matching hotel scenario.
hotel = run(CREDITS, ["check", "--scenario", "Hilton London, 2 nights"], home)
assert hotel.returncode == 0 and "Hotel stay certificate" in hotel.stdout, \
f"the brand-tagged COMPANION should surface for a Hilton scenario:\n{hotel.stdout}"
def test_unambiguous_brand_phrase_still_matches():
# The flip side: the disambiguated multi-word phrase must still match its chain.
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Free night", "--value",
"1 night", "--passenger", "Baruch", "--brand", "Marriott"], home).returncode == 0
r = run(CREDITS, ["check", "--scenario", "Courtyard by Marriott, 2 nights"], home)
assert r.returncode == 0 and "Free night" in r.stdout and "MARRIOTT" in r.stdout, \
f"the 'Courtyard by Marriott' phrase should surface the MARRIOTT credit:\n{r.stdout}"
def test_airline_only_check_unchanged_back_compat():
# Back-compat: a store with only airline credits behaves exactly as before brand existed.
home = fresh_home()
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "AA repo credit", "--value",
"189.50", "--passenger", "Kid", "--airline", "AA"], home).returncode == 0
r = run(CREDITS, ["check", "--scenario", "American Airlines BNA-ORD economy"], home)
assert r.returncode == 0 and "AA repo credit" in r.stdout, \
f"airline matching must still work unchanged:\n{r.stdout}"
# ── complaints-bank hotel store (--store hotel) ───────────────────────────────
_HOTEL_FILE_ARGS = [
"--store", "hotel", "file",
"--brand", "Hilton", "--property", "Hilton London Angel Islington",
"--reservation", "3434402137", "--stay-dates", "2026-05-05/2026-05-08",
"--loyalty-status", "Hilton Honors Gold", "--passenger", "Baruch Sadogursky",
"--category", "HABITABILITY", "--severity", "MAJOR",
"--summary", "No hot water for 2 of 3 nights", "--outcome", "Full stay refund + points",
]
_AIRLINE_FILE_ARGS = [
"file", "--airline", "DL", "--flight", "DL1234", "--flight-date", "2026-01-15",
"--route", "BNA-JFK", "--passenger", "Baruch Sadogursky", "--category", "CANCELLATION",
"--severity", "MAJOR", "--summary", "Cancelled 2hrs before", "--outcome", "Full refund",
]
def test_hotel_store_file_list_check_resolve_roundtrip():
# The whole point of #3: a hotel complaint can be filed, listed, pattern-checked, and
# resolved through the same script — the schema the airline-only `file` used to reject.
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
f = run(BANK, _HOTEL_FILE_ARGS, home)
assert f.returncode == 0 and "#1" in f.stdout and "HABITABILITY" in f.stdout, \
f"hotel file failed:\n{f.stdout}{f.stderr}"
lst = run(BANK, ["--store", "hotel", "list"], home)
assert lst.returncode == 0 and "Hilton London Angel" in lst.stdout and "HABITABILITY" in lst.stdout, \
f"hotel list missing the entry:\n{lst.stdout}"
chk = run(BANK, ["--store", "hotel", "check", "--brand", "Hilton", "--passenger", "Baruch"], home)
assert chk.returncode == 0 and "HABITABILITY" in chk.stdout, f"hotel check failed:\n{chk.stdout}"
rv = run(BANK, ["--store", "hotel", "resolve", "--id", "1", "--resolution", "RESOLVED",
"--note", "2-night refund + 30K Honors points"], home)
assert rv.returncode == 0, f"hotel resolve failed:\n{rv.stderr}"
chk2 = run(BANK, ["--store", "hotel", "check", "--brand", "Hilton"], home)
assert "RESOLVED" in chk2.stdout, f"resolution not reflected:\n{chk2.stdout}"
def test_hotel_and_airline_stores_are_independent():
# Separate files, independent ID spaces (both start at #1), neither leaks into the other's
# list — the back-compat guarantee for the default airline store.
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
a = run(BANK, _AIRLINE_FILE_ARGS, home)
h = run(BANK, _HOTEL_FILE_ARGS, home)
assert "#1" in a.stdout and "#1" in h.stdout, \
f"each store should have its own ID space starting at 1:\n{a.stdout}\n{h.stdout}"
air = run(BANK, ["list"], home) # default store = airline
assert "DL1234" in air.stdout, f"airline list should show the airline complaint:\n{air.stdout}"
assert "Hilton" not in air.stdout and "HABITABILITY" not in air.stdout, \
f"the hotel complaint must not leak into the airline list:\n{air.stdout}"
hot = run(BANK, ["--store", "hotel", "list"], home)
assert "Hilton" in hot.stdout and "DL1234" not in hot.stdout, \
f"the airline complaint must not leak into the hotel list:\n{hot.stdout}"
def test_category_vocab_is_store_specific():
# Hotel rejects an airline-only category and vice-versa; each store enforces its own vocab.
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
# airline category in the hotel store → rejected
bad_hotel = ["--store", "hotel", "file", "--brand", "Hilton", "--property", "P",
"--reservation", "1", "--stay-dates", "2026-05-05/2026-05-06",
"--loyalty-status", "Gold", "--passenger", "B", "--category", "CANCELLATION",
"--severity", "MINOR", "--summary", "s", "--outcome", "o"]
r1 = run(BANK, bad_hotel, home)
assert r1.returncode == 1 and "invalid category" in r1.stderr.lower(), \
f"airline category must be rejected in the hotel store:\n{r1.stderr}"
# hotel category in the airline store → rejected
bad_airline = ["file", "--airline", "DL", "--flight", "DL1", "--flight-date", "2026-01-15",
"--route", "BNA-JFK", "--passenger", "B", "--category", "HABITABILITY",
"--severity", "MINOR", "--summary", "s", "--outcome", "o"]
r2 = run(BANK, bad_airline, home)
assert r2.returncode == 1 and "invalid category" in r2.stderr.lower(), \
f"hotel category must be rejected in the airline store:\n{r2.stderr}"
def test_hotel_file_missing_store_specific_args_is_rejected():
# `--store hotel file` with only the shared args must fail with an actionable message
# naming the missing hotel-specific flags (argparse can't enforce them — cmd_file does).
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
r = run(BANK, ["--store", "hotel", "file", "--passenger", "B", "--category", "SERVICE",
"--severity", "MINOR", "--summary", "s", "--outcome", "o"], home)
assert r.returncode == 1, f"missing hotel args should exit 1, got {r.returncode}\n{r.stdout}{r.stderr}"
assert "requires" in r.stderr.lower() and "--brand" in r.stderr, \
f"error should name the missing hotel flags:\n{r.stderr}"
assert "traceback" not in r.stderr.lower(), f"crashed instead of clean error:\n{r.stderr}"
def _bank_file(home, name):
return os.path.join(store_path(home, "complaint-bank"), name)
def test_link_accepts_hotel_only_bank():
# A bank holding only hotel-complaints.md (no airline complaints yet) must still be
# linkable — hotel-complaints.md is a valid bank-existence marker too. Before the fix,
# `link` errored because it only looked for complaints.md.
home = fresh_home()
cloud = _mktemp(prefix="ffa-test-cloud-")
assert run(BANK, ["init", "--path", cloud], home).returncode == 0
assert run(BANK, _HOTEL_FILE_ARGS, home).returncode == 0
os.remove(os.path.join(cloud, "complaints.md")) # make it hotel-only
os.unlink(store_path(home, "complaint-bank")) # simulate a fresh machine: data in cloud
r = run(BANK, ["link", "--path", cloud], home)
assert r.returncode == 0, f"link to a hotel-only bank should succeed:\n{r.stderr}"
lst = run(BANK, ["--store", "hotel", "list"], home)
assert "Hilton" in lst.stdout, f"hotel data must survive the link:\n{lst.stdout}"
def test_interactive_init_does_not_wipe_hotel_only_bank():
# Data-loss guard: a bank with hotel complaints but no airline complaints must read as
# POPULATED, so interactive `init` refuses to reinitialize (which would rmtree the dir and
# destroy the hotel data). Before the fix, emptiness was judged from complaints.md alone.
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
assert run(BANK, _HOTEL_FILE_ARGS, home).returncode == 0
os.remove(_bank_file(home, "complaints.md")) # hotel-only bank
r = run(BANK, ["init"], home, stdin_text="y\n") # 'y' would confirm a wipe, if offered
assert r.returncode == 0, f"{r.stdout}{r.stderr}"
assert "has filed complaints" in r.stdout.lower(), \
f"a hotel-only bank must read as populated, not offer to reinitialize:\n{r.stdout}"
assert os.path.isfile(_bank_file(home, "hotel-complaints.md")), \
"hotel data must not be wiped"
lst = run(BANK, ["--store", "hotel", "list"], home)
assert "Hilton" in lst.stdout, f"hotel data must survive:\n{lst.stdout}"
# ── JSON output contract (credits-tracker only) ───────────────────────────────
def _json_out(result):
"""Parse a command's stdout as one JSON object, failing loudly if it is not."""
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise AssertionError(f"stdout was not JSON ({exc}):\n{result.stdout}")
def test_every_command_emits_one_json_object():
"""--json produces a single parseable object on stdout for every subcommand."""
home = _mktemp("json-all-")
init = run(CREDITS, ["init", "--json", "--default"], home)
assert _json_out(init)["state"] == "ready"
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "DL credit", "--value", "200.00",
"--airline", "DL", "--passenger", "Baruch Sadogursky"], home)
for argv in (["status", "--json"], ["list", "--json"], ["expiring", "--json"],
["summary", "--json"],
["check", "--json", "--scenario", "Delta business JFK-CDG"]):
out = _json_out(run(CREDITS, argv, home))
assert isinstance(out, dict), f"{argv[0]} did not emit an object: {out}"
def test_status_json_states_match_exit_codes():
"""The JSON state and the exit code report the same readiness in every branch."""
ready_home = _mktemp("json-ready-")
run(CREDITS, ["init", "--default"], ready_home)
res = run(CREDITS, ["status", "--json"], ready_home)
assert res.returncode == 0 and _json_out(res)["state"] == "ready"
missing_home = _mktemp("json-missing-")
res = run(CREDITS, ["status", "--json"], missing_home)
assert res.returncode == 3 and _json_out(res)["state"] == "missing"
invalid_home = _mktemp("json-invalid-")
os.makedirs(os.path.join(invalid_home, ".claude"), exist_ok=True)
with open(os.path.join(invalid_home, ".claude", "travel-credits"), "w") as fh:
fh.write("not a directory")
res = run(CREDITS, ["status", "--json"], invalid_home)
assert res.returncode == 4 and _json_out(res)["state"] == "invalid"
assert _json_out(res)["reason"], "an invalid store must say why"
def test_check_json_separates_other_passenger_matches():
"""A family member off the trip lands in other_passenger_matches, not matches."""
home = _mktemp("json-check-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "AA kid credit", "--value", "189.50",
"--airline", "AA", "--passenger", "Kid Sadogursky"], home)
out = _json_out(run(CREDITS, ["check", "--json", "--scenario",
"American Airlines BNA-ORD economy repo",
"--passengers", "Baruch"], home))
assert out["matches"] == []
assert len(out["other_passenger_matches"]) == 1
other = out["other_passenger_matches"][0]
assert other["passenger_on_trip"] is False
assert other["reasons"], "a match must carry its reasons"
assert out["airlines_detected"] == ["AA"]
def test_add_error_emits_structured_payload():
"""An agent reads a failure from JSON rather than scraping stderr prose."""
home = _mktemp("json-err-")
run(CREDITS, ["init", "--default"], home)
res = run(CREDITS, ["add", "--json", "--type", "NOPE", "--desc", "x", "--value", "1"], home)
assert res.returncode == 1
assert _json_out(res)["error"] == "invalid_type"
def test_interactive_init_refuses_json_mode():
"""Bare `init --json` cannot answer prompts on the user's behalf."""
home = _mktemp("json-interactive-")
res = run(CREDITS, ["init", "--json"], home)
assert res.returncode == 2
assert _json_out(res)["error"] == "interactive_required"
def test_prose_remains_the_default():
"""Existing callers that read tables are unaffected by the JSON path."""
home = _mktemp("json-default-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "DL credit", "--value", "200.00",
"--airline", "DL"], home)
out = run(CREDITS, ["list"], home).stdout
assert "DL credit" in out
try:
json.loads(out)
except json.JSONDecodeError:
return
raise AssertionError("default output should be prose, not JSON")
def test_invalid_expiry_rejected_before_any_write():
"""A malformed --expiry fails cleanly and leaves the inventory untouched."""
home = _mktemp("json-expiry-")
run(CREDITS, ["init", "--default"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
before = fh.read()
res = run(CREDITS, ["add", "--json", "--type", "ECREDIT", "--desc", "Bad date",
"--value", "10.00", "--expiry", "not-a-date"], home)
assert res.returncode == 1, f"expected a clean failure, got {res.returncode}"
assert _json_out(res)["error"] == "invalid_expiry"
assert "Traceback" not in res.stderr, f"died in a traceback:\n{res.stderr}"
with open(inventory) as fh:
after = fh.read()
assert after == before, "a rejected credit must not reach the store"
assert "Bad date" not in after
def test_invalid_expiry_rejected_in_prose_mode_too():
"""The same guard holds without --json; the store is not mutated either way."""
home = _mktemp("prose-expiry-")
run(CREDITS, ["init", "--default"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
before = fh.read()
res = run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Bad date",
"--value", "10.00", "--expiry", "2026-13-45"], home)
assert res.returncode == 1
assert "Traceback" not in res.stderr
with open(inventory) as fh:
assert fh.read() == before
def test_uninitialized_store_still_emits_json():
"""A bootstrap failure honours the contract instead of leaving stdout empty."""
home = _mktemp("json-uninit-")
res = run(CREDITS, ["list", "--json"], home)
assert res.returncode != 0
assert _json_out(res)["error"] == "store_not_initialized"
def test_bad_argument_still_emits_json():
"""An argparse failure exits before args exist and still emits an object."""
home = _mktemp("json-badarg-")
res = run(CREDITS, ["list", "--json", "--nonsense"], home)
assert res.returncode != 0
assert _json_out(res)["error"] == "command_failed"
def test_every_json_failure_path_emits_an_object():
"""No --json invocation may exit with unparseable stdout."""
uninit = _mktemp("json-fail-uninit-")
ready = _mktemp("json-fail-ready-")
run(CREDITS, ["init", "--default"], ready)
cases = [
(uninit, ["list", "--json"]),
(uninit, ["summary", "--json"]),
(uninit, ["expiring", "--json"]),
(ready, ["use", "--json", "--id", "999"]),
(ready, ["add", "--json", "--type", "NOPE", "--desc", "x", "--value", "1"]),
(ready, ["add", "--json", "--type", "ECREDIT", "--desc", "x", "--value", "1",
"--expiry", "nope"]),
(ready, ["init", "--json"]),
]
for home, argv in cases:
res = run(CREDITS, argv, home)
assert res.returncode != 0, f"{argv} unexpectedly succeeded"
out = _json_out(res)
assert "error" in out, f"{argv} emitted no error key: {out}"
def test_check_reports_detections_on_an_empty_store():
"""Scenario detection does not depend on whether the store holds credits."""
home = _mktemp("json-empty-check-")
run(CREDITS, ["init", "--default"], home)
out = _json_out(run(CREDITS, ["check", "--json", "--scenario",
"Delta business JFK-CDG"], home))
assert out["airlines_detected"] == ["DL"], out
assert out["match_count"] == 0
hotel = _json_out(run(CREDITS, ["check", "--json", "--scenario",
"Hilton London, 3 nights"], home))
assert hotel["brands_detected"] == ["HILTON"], hotel
# ── complaints-bank --json contract ───────────────────────────────────────────
def _bank_json(args, home, stdin_text=None):
"""Run complaints-bank with --json and return (payload, exit code)."""
r = run(BANK, args, home, stdin_text=stdin_text)
assert r.stdout.strip(), f"{args}: stdout empty under --json\n{r.stderr}"
return json.loads(r.stdout), r.returncode
_AIRLINE_FILE = ["file", "--airline", "DL", "--flight", "DL1234",
"--flight-date", "2026-01-15", "--route", "ATL-SFO",
"--passenger", "J Baruch", "--category", "DELAY",
"--severity", "MAJOR", "--summary", "6h delay", "--outcome", "refund"]
_HOTEL_FILE = ["--store", "hotel", "file", "--brand", "Hilton",
"--property", "Hilton London", "--reservation", "34344",
"--stay-dates", "2026-05-05/2026-05-08", "--loyalty-status", "Gold",
"--passenger", "J Baruch", "--category", "HABITABILITY",
"--severity", "MAJOR", "--summary", "No hot water", "--outcome", "refund"]
def _filed_bank(prefix, filing=None):
home = _mktemp(prefix)
assert run(BANK, ["init", "--default"], home).returncode == 0
assert run(BANK, filing or _AIRLINE_FILE, home).returncode == 0
return home
def test_bank_update_corrects_fields_assessed_wrong_at_intake():
"""A complaint is filed from what is known then; some of it turns out wrong later.
Without this the routes were hand-editing a file the bank's contract forbids, or
re-filing — and `check` counts complaints to find patterns, so a duplicate inflates
a number `complaint-patterns` is about to assert as fact.
"""
home = _filed_bank("bank-update-")
payload = _json_out(run(BANK, ["update", "--json", "--id", "1", "--severity",
"RIGHTS_VIOLATION", "--summary", "Cancelled, no rebooking"],
home))
assert payload["fields_changed"] == ["Severity", "Summary"], payload
listed = _json_out(run(BANK, ["list", "--json"], home))["complaints"][0]
assert listed["severity"] == "RIGHTS_VIOLATION"
assert listed["summary"] == "Cancelled, no rebooking"
assert listed["outcome_requested"] == "refund", "an omitted field must be preserved"
assert listed["route"] == "ATL-SFO"
checked = _json_out(run(BANK, ["check", "--json", "--airline", "DL"], home))
assert len(checked["matches"]) == 1, f"update must not duplicate the complaint: {checked}"
def test_bank_update_rebuilds_the_heading_when_the_incident_changes():
"""The heading names the incident; leaving it stale would describe an older record."""
home = _filed_bank("bank-heading-")
run(BANK, ["update", "--json", "--id", "1", "--category", "CANCELLATION",
"--route", "ATL-LAX"], home)
bank = os.path.join(home, ".claude", "complaint-bank", "complaints.md")
with open(bank) as fh:
text = fh.read()
assert "### #1 — [CANCELLATION] DL1234 ATL-LAX 2026-01-15" in text, text
def test_bank_update_validates_the_vocabulary_per_store():
"""Airline and hotel categories are different sets; each store rejects the other's."""
airline = _filed_bank("bank-vocab-air-")
r = run(BANK, ["update", "--json", "--id", "1", "--category", "HABITABILITY"], airline)
assert r.returncode != 0
assert _json_out(r)["error"] == "invalid_category", r.stdout
hotel = _filed_bank("bank-vocab-hotel-", _HOTEL_FILE)
r = run(BANK, ["--store", "hotel", "update", "--json", "--id", "1",
"--category", "TARMAC"], hotel)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "invalid_category" and payload["store"] == "hotel", payload
assert run(BANK, ["--store", "hotel", "update", "--json", "--id", "1",
"--category", "CLEANLINESS"], hotel).returncode == 0
# Assert the store, not the report. The hotel schema carries Category in the heading
# AND as a bullet, and parse_complaints() lets the bullet win — so rebuilding only
# the heading reported "Category" changed while list kept reporting the old value.
assert _json_out(run(BANK, ["--store", "hotel", "list", "--json"], hotel)
)["complaints"][0]["category"] == "CLEANLINESS"
def test_bank_update_does_not_add_a_category_bullet_to_an_airline_record():
"""Airline records carry category in the heading alone; adding a bullet reshapes them."""
home = _filed_bank("bank-cat-airline-")
assert run(BANK, ["update", "--json", "--id", "1",
"--category", "CANCELLATION"], home).returncode == 0
assert _json_out(run(BANK, ["list", "--json"], home)
)["complaints"][0]["category"] == "CANCELLATION"
bank = os.path.join(home, ".claude", "complaint-bank", "complaints.md")
with open(bank) as fh:
assert "**Category**" not in fh.read(), "airline records keep category in the heading"
def test_bank_update_rejects_a_bad_severity_and_writes_nothing():
home = _filed_bank("bank-sev-")
bank = os.path.join(home, ".claude", "complaint-bank", "complaints.md")
with open(bank) as fh:
before = fh.read()
r = run(BANK, ["update", "--json", "--id", "1", "--severity", "CATASTROPHIC"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "invalid_severity", r.stdout
with open(bank) as fh:
assert fh.read() == before, "a rejected update must not touch the bank"
def test_bank_update_requires_a_field_and_reports_an_unknown_id():
home = _filed_bank("bank-guards-")
r = run(BANK, ["update", "--json", "--id", "1"], home)
assert r.returncode != 0 and _json_out(r)["error"] == "no_fields_given", r.stdout
r = run(BANK, ["update", "--json", "--id", "99", "--summary", "x"], home)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "not_found" and payload["store"] == "airline", payload
def test_bank_update_refuses_a_newline_in_a_value():
"""check counts complaints, so an injected one inflates a number a letter asserts."""
home = _filed_bank("bank-inject-")
r = run(BANK, ["update", "--json", "--id", "1", "--summary",
"x\n### #99 — [DELAY] Injected\n- **Severity**: MINOR"], home)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "multiline_value" and payload["fields"] == ["summary"], payload
assert len(_json_out(run(BANK, ["check", "--json", "--airline", "DL"], home))["matches"]) == 1
def test_bank_update_preserves_fields_the_formatter_does_not_know():
home = _filed_bank("bank-preserve-")
bank = os.path.join(home, ".claude", "complaint-bank", "complaints.md")
with open(bank) as fh:
text = fh.read()
with open(bank, "w") as fh:
fh.write(text.replace("- **Resolution**:", "- **Legacy note**: keep me\n- **Resolution**:"))
run(BANK, ["update", "--json", "--id", "1", "--summary", "revised"], home)
with open(bank) as fh:
assert "- **Legacy note**: keep me" in fh.read()
def test_bank_update_does_not_own_the_resolution_transition():
"""`resolve` owns it, exactly as `use` owns the credits transition."""
home = _filed_bank("bank-resolution-")
r = run(BANK, ["update", "--json", "--id", "1", "--resolution", "RESOLVED"], home)
assert r.returncode != 0, "update must not accept --resolution"
resolved = _json_out(run(BANK, ["resolve", "--json", "--id", "1",
"--resolution", "RESOLVED"], home))
assert resolved["updated"]["resolution"] == "RESOLVED", resolved
def test_bank_update_edits_a_resolved_complaint():
"""A resolved complaint is still live pattern data — a wrong severity should be fixable."""
home = _filed_bank("bank-resolved-")
run(BANK, ["resolve", "--json", "--id", "1", "--resolution", "DENIED"], home)
payload = _json_out(run(BANK, ["update", "--json", "--id", "1",
"--severity", "MINOR"], home))
assert payload["fields_changed"] == ["Severity"], payload
listed = _json_out(run(BANK, ["list", "--json"], home))["complaints"][0]
assert listed["severity"] == "MINOR" and listed["resolution"] == "DENIED", listed
def test_bank_json_every_subcommand_emits_one_object():
# script-delegation: a skill-invoked deterministic script emits structured data. Every
# subcommand, success and failure alike, has to parse — an empty stdout reads as a
# crash rather than a result.
home = fresh_home()
payload, code = _bank_json(["status", "--json"], home)
assert payload["state"] == "missing" and code == 3, payload
payload, code = _bank_json(["list", "--json"], home)
assert payload["error"] == "bank_not_initialized" and code == 2, payload
assert _bank_json(["init", "--default", "--json"], home)[1] == 0
assert _bank_json(["status", "--json"], home)[0]["state"] == "ready"
payload, code = _bank_json([*_AIRLINE_FILE, "--json"], home)
assert code == 0 and payload["filed"]["id"] == 1, payload
assert payload["filed"]["airline"] == "DL", payload
assert _bank_json(["list", "--json"], home)[0]["count"] == 1
assert _bank_json(["pending", "--json"], home)[0]["count"] == 1
assert _bank_json(["check", "--airline", "DL", "--json"], home)[0]["count"] == 1
payload, code = _bank_json(
["resolve", "--id", "1", "--resolution", "RESOLVED", "--note", "75K", "--json"], home)
assert code == 0 and payload["updated"]["resolution"] == "RESOLVED", payload
assert _bank_json(["pending", "--json"], home)[0]["count"] == 0
def test_bank_json_failures_are_structured():
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
cases = [
("invalid_category",
[*_AIRLINE_FILE[:-6], "--category", "NOPE", "--severity", "MAJOR",
"--summary", "s", "--outcome", "o", "--json"]),
("not_found", ["resolve", "--id", "99", "--resolution", "RESOLVED", "--json"]),
("missing_required_args",
["file", "--passenger", "P", "--category", "DELAY", "--severity", "MAJOR",
"--summary", "s", "--outcome", "o", "--json"]),
("missing_required_args", ["check", "--json"]),
]
for expected, args in cases:
payload, code = _bank_json(args, home)
assert code != 0, f"{args} should exit non-zero"
assert payload["error"] == expected, f"{args}: wanted {expected}, got {payload}"
def test_bank_json_empty_results_are_valid_answers():
# A count of 0 is an answer, not a failure — the prose path prints "No complaints found."
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
for args, key in ((["list", "--json"], "count"), (["pending", "--json"], "count")):
payload, code = _bank_json(args, home)
assert code == 0 and payload[key] == 0, f"{args}: {payload}"
payload, code = _bank_json(["check", "--airline", "DL", "--json"], home)
assert code == 0 and payload["count"] == 0 and payload["matches"] == [], payload
def test_bank_json_hotel_store_is_covered_too():
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
payload, code = _bank_json(["--store", "hotel", *_HOTEL_FILE_ARGS, "--json"], home)
assert code == 0, payload
assert payload["store"] == "hotel" and payload["filed"]["brand"] == "Hilton", payload
assert _bank_json(["--store", "hotel", "list", "--json"], home)[0]["count"] == 1
# Airline store stays empty — the two stores never leak into each other.
assert _bank_json(["list", "--json"], home)[0]["count"] == 0
def test_bank_prose_mode_is_unchanged_by_default():
# Back-compat: every existing call site omits --json and must still get the tables.
home = fresh_home()
assert run(BANK, ["init", "--default"], home).returncode == 0
assert run(BANK, _AIRLINE_FILE, home).returncode == 0
r = run(BANK, ["list"], home)
assert "Resolution" in r.stdout and "DL1234" in r.stdout, r.stdout
assert not r.stdout.lstrip().startswith("{"), "prose mode must not emit JSON"
# ── schema_version stamping (credits-tracker only) ────────────────────────────
def test_added_credit_carries_schema_version():
"""Every record written must carry the schema version (stateful-artifacts)."""
home = _mktemp("schemaver-")
run(CREDITS, ["init", "--default"], home)
assert run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Test credit",
"--value", "100.00", "--airline", "DL"], home).returncode == 0
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
assert vline(CURRENT_SCHEMA) in text, f"no schema version stamped:\n{text}"
def _strip_versions(inventory):
"""Simulate records written before versioning existed."""
with open(inventory) as fh:
text = fh.read()
stripped = "\n".join(l for l in text.split("\n") if "**Schema version**" not in l)
with open(inventory, "w") as fh:
fh.write(stripped)
assert "**Schema version**" not in stripped
return stripped
def test_a_non_owner_write_does_not_migrate_other_records():
"""stateful-artifacts reserves migration to the owner skill.
Every skill that logs compensation calls this script directly, so `add` runs
under a non-owner writer. It stamps the record it is itself writing and
leaves everyone else's alone — the store is upgraded by `migrate`, not as a
side effect of somebody logging a voucher.
"""
home = _mktemp("schemaver-nonowner-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Legacy credit",
"--value", "50.00", "--airline", "AA"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
_strip_versions(inventory)
assert run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Second credit",
"--value", "25.00", "--airline", "AA"], home).returncode == 0
with open(inventory) as fh:
after = fh.read()
assert after.count(vline(CURRENT_SCHEMA)) == 1, (
f"a non-owner write must stamp only its own record:\n{after}")
assert "Legacy credit" in after, "the untouched record must survive verbatim"
def test_migrate_stamps_records_written_before_versioning():
"""The owner's migrate run is what brings a pre-versioning store up to date."""
home = _mktemp("schemaver-migrate-")
run(CREDITS, ["init", "--default"], home)
for desc in ("Legacy one", "Legacy two"):
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", desc,
"--value", "50.00", "--airline", "AA"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
_strip_versions(inventory)
r = run(CREDITS, ["migrate", "--json"], home)
assert r.returncode == 0, f"{r.stdout}{r.stderr}"
payload = _json_out(r)
assert payload["changed"] is True
assert payload["stamped"] == 2, payload
with open(inventory) as fh:
after = fh.read()
assert after.count(vline(CURRENT_SCHEMA)) == 2, f"not stamped:\n{after}"
assert "Legacy one" in after and "Legacy two" in after
def test_an_unversioned_record_is_not_consumed_until_migrated():
"""stateful-artifacts puts a schema_version on every record.
Without one a reader cannot know the record's shape, so it declines it rather
than guessing — and the owner's migrate is what makes it readable. The router
runs migrate ahead of every read, so a pre-versioning store heals on first use.
"""
home = _mktemp("schemaver-absent-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Pre-versioning credit",
"--value", "20.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
_strip_versions(inventory)
listed = run(CREDITS, ["list", "--json"], home)
assert _json_out(listed)["count"] == 0, "an unversioned record must not be consumed"
assert "no schema version" in listed.stderr, listed.stderr
assert "migrate" in listed.stderr, "the warning must name the recovery"
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["stamped"] == 1, payload
assert payload["unconsumable"] == 0, payload
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1
def test_migrate_is_idempotent():
"""A store already current is left byte-identical and reports no change."""
home = _mktemp("schemaver-idem-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Current credit",
"--value", "50.00", "--airline", "AA"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
before = fh.read()
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["changed"] is False, payload
assert payload["stamped"] == 0 and payload["upgraded"] == 0, payload
with open(inventory) as fh:
assert fh.read() == before, "an idempotent migrate must not rewrite the store"
def test_newer_schema_version_is_skipped_and_its_id_reserved():
"""A record newer than this script reads as unusable, and its id is not reused."""
home = _mktemp("schemaver-newer-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Readable credit",
"--value", "10.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
# Simulate a record written by a future owner.
text = text.replace(vline(CURRENT_SCHEMA), vline(99))
with open(inventory, "w") as fh:
fh.write(text)
listed = run(CREDITS, ["list"], home)
assert "Readable credit" not in listed.stdout, (
f"a newer-versioned record must not be consumed:\n{listed.stdout}")
added = run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "New credit",
"--value", "20.00", "--airline", "AA"], home)
assert added.returncode == 0
assert "#2" in added.stdout, (
f"id must not be reused over an unreadable record:\n{added.stdout}")
def test_a_non_owner_read_declines_an_older_record():
"""An older record is 'no usable prior state' to a reader, not stale data to consume.
Migration Policy reserves upgrading to the owner. Every caller other than the
owner skill is a non-owner reader, so it must decline an off-version record
rather than read it under a shape the owner has since moved past.
"""
home = _mktemp("schemaver-nonowner-read-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Old-shape credit",
"--value", "15.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA), vline(PRIOR_SCHEMA)))
listed = run(CREDITS, ["list", "--json"], home)
assert _json_out(listed)["count"] == 0, "an older record must not be consumed"
assert "older than" in listed.stderr, f"the skip must be reported:\n{listed.stderr}"
assert "migrate" in listed.stderr, "the warning must name the recovery"
# The owner's migrate is what makes it readable again.
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["upgraded"] == 1, payload
with open(inventory) as fh:
after = fh.read()
assert vline(PRIOR_SCHEMA) not in after, f"stale version survived:\n{after}"
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1, \
"the record must be readable once the owner has upgraded it"
def test_migrate_does_not_rewrite_a_version_line_over_spacing():
"""A current record whose version line spaces differently is still a no-op.
Canonicalizing the line would report changed: true for a whitespace difference
alone, and the owner runs migrate ahead of every read — so a cosmetic diff
would rewrite the store on every one of them.
"""
home = _mktemp("schemaver-spacing-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Spaced credit",
"--value", "15.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
spaced = text.replace(vline(CURRENT_SCHEMA), f"- **Schema version**: {CURRENT_SCHEMA}")
with open(inventory, "w") as fh:
fh.write(spaced)
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["already_current"] == 1, payload
assert payload["changed"] is False, f"a spacing difference is not a migration: {payload}"
with open(inventory) as fh:
assert fh.read() == spaced, "the store must be left byte-identical"
def test_migrate_sees_an_indented_version_line_the_way_the_parser_does():
"""Migration and parsing must recognize a field line by the same rule.
parse_credits() strips before matching. Anchoring migration on column zero
made an indented version line invisible to it and visible to the parser:
migrate re-stamped the record and reported the store wholly readable, then
every later command dropped it. The router's Step 3 gate reads that report,
so the divergence turned into a partial inventory presented as the whole one.
"""
home = _mktemp("schemaver-indent-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Indented newer",
"--value", "10.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA), " - **Schema version**: 99"))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["skipped_newer"] == 1, f"indented newer record not recognized: {payload}"
assert payload["stamped"] == 0, f"a second version line was spliced in: {payload}"
assert payload["changed"] is False, f"an unmigratable record must not be rewritten: {payload}"
# The report must agree with what the reader actually consumes.
assert payload["unconsumable"] == 1, payload
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 0
def test_migrate_reports_unconsumable_from_the_parser_not_the_buckets():
"""`unconsumable` is measured by asking the parser, so it holds whatever the cause."""
home = _mktemp("schemaver-unconsumable-")
run(CREDITS, ["init", "--default"], home)
for desc in ("Readable one", "Readable two"):
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", desc,
"--value", "10.00", "--airline", "DL"], home)
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["unconsumable"] == 0, payload
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 2
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA), vline(99), 1))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["unconsumable"] == 1, payload
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1
def test_next_id_counts_an_indented_record():
"""An id is never reissued over a record the heading scan failed to see."""
home = _mktemp("schemaver-indentid-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Indented record",
"--value", "10.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace("### #1 ", " ### #1 "))
added = _json_out(run(CREDITS, ["add", "--json", "--type", "VOUCHER",
"--desc", "Next record", "--value", "5.00"], home))
assert added["added"]["id"] == 2, f"id reissued over an indented record: {added}"
def test_migrate_finds_a_version_field_anywhere_in_the_record():
"""The parser accepts the field anywhere in a record; migration must agree.
Deciding "unversioned" from the line after the heading alone spliced a second
version field into a record that already carried one further down, leaving the
record's version order-dependent.
"""
home = _mktemp("schemaver-late-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Late version field",
"--value", "10.00"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
lines = fh.read().split("\n")
vi = next(k for k, l in enumerate(lines) if l.startswith("- **Schema version**"))
lines.insert(vi + 1, lines.pop(vi)) # push it below the next field
with open(inventory, "w") as fh:
fh.write("\n".join(lines))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["stamped"] == 0, f"a second version field was spliced in: {payload}"
assert payload["already_current"] == 1, payload
assert payload["changed"] is False, payload
with open(inventory) as fh:
after = fh.read()
assert after.count("**Schema version**") == 1, f"duplicate version field:\n{after}"
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1
def test_migrate_collapses_duplicate_version_fields():
"""Two version fields make a record's version depend on read order — repair it."""
home = _mktemp("schemaver-dup-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Duplicated", "--value", "10.00"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA),
vline(CURRENT_SCHEMA) + "\n" + vline(CURRENT_SCHEMA)))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["changed"] is True, payload
with open(inventory) as fh:
after = fh.read()
assert after.count("**Schema version**") == 1, f"duplicates survived:\n{after}"
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1
def test_upgrade_record_body_return_value_is_applied():
"""A future non-identity upgrade must actually transform the body.
The upgrade step's return was discarded, so a SCHEMA_VERSION > 1 rollout would
have bumped every record's version line over an untransformed body — the exact
silent-corruption the version exists to make auditable.
"""
tracker = _load_tracker()
# setattr, not attribute assignment: the module is loaded by path, so a static
# checker has no declaration to bind these names to.
setattr(tracker, "SCHEMA_VERSION", 2)
setattr(tracker, "upgrade_record_body", lambda body, _v: body + ["- **Added by v2**: yes"])
store = ("<!-- CREDITS_START -->\n"
"### #1 — [ECREDIT] Old shape\n"
"- **Schema version**: 1\n"
"- **Value**: 10.00\n"
"<!-- CREDITS_END -->\n")
migrated, stats = tracker.stamp_schema_version(store)
assert stats["upgraded"] == 1, stats
assert "- **Added by v2**: yes" in migrated, f"upgrade output discarded:\n{migrated}"
assert "- **Schema version**: 2" in migrated, migrated
assert migrated.count("**Schema version**") == 1, migrated
def test_migrate_reports_an_unparseable_version_line():
"""A version line that is not an integer is counted, not silently swallowed.
The router branches on this field to stop rather than present a partial
inventory, so the count has to be real.
"""
home = _mktemp("schemaver-garbage-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Hand-edited credit",
"--value", "15.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA), "- **Schema version**: v1-ish"))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["unreadable"] == 1, payload
with open(inventory) as fh:
assert "- **Schema version**: v1-ish" in fh.read(), "must not be guessed at"
listed = _json_out(run(CREDITS, ["list", "--json"], home))
assert listed["count"] == 0, f"an unreadable record must not be consumed: {listed}"
def test_migrate_does_not_rewrite_a_newer_record_down():
"""An owner that cannot read a record must not rewrite its version either."""
home = _mktemp("schemaver-preserve-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--type", "ECREDIT", "--desc", "Future credit",
"--value", "15.00", "--airline", "DL"], home)
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace(vline(CURRENT_SCHEMA), vline(99)))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["skipped_newer"] == 1, payload
with open(inventory) as fh:
after = fh.read()
assert vline(99) in after, f"newer record was downgraded:\n{after}"
# And a plain non-owner write leaves it alone too.
run(CREDITS, ["add", "--type", "VOUCHER", "--desc", "Current credit",
"--value", "5.00", "--airline", "AA"], home)
with open(inventory) as fh:
assert vline(99) in fh.read()
# ── update ────────────────────────────────────────────────────────────────────
def _voucher_home(prefix):
home = _mktemp(prefix)
assert run(CREDITS, ["init", "--default"], home).returncode == 0
assert run(CREDITS, ["add", "--json", "--type", "VOUCHER", "--desc", "150 voucher",
"--value", "150.00", "--airline", "BA"], home).returncode == 0
return home
def test_update_fills_in_details_that_arrive_later():
"""The workflow this exists for: the airline confirms, the details follow.
Before it, the only routes were hand-editing a file whose header forbids it, or
marking the half-entered credit used and re-adding it — which pollutes the archive
with a ghost record and burns an id.
"""
home = _voucher_home("update-later-")
payload = _json_out(run(CREDITS, ["update", "--json", "--id", "1",
"--expiry", "2024-06-30",
"--confirmation", "BA-VCH-99812",
"--restrictions", "Non-transferable"], home))
assert payload["fields_changed"] == ["Confirmation", "Expiry", "Restrictions"], payload
credit = _json_out(run(CREDITS, ["list", "--json"], home))["credits"][0]
assert credit["expiry"] == "2024-06-30"
assert credit["confirmation"] == "BA-VCH-99812"
assert credit["restrictions"] == "Non-transferable"
assert credit["value"] == "150.00", "an omitted field must be preserved, not cleared"
assert credit["airline"] == "BA"
def test_update_replaces_an_existing_field_and_the_description():
home = _voucher_home("update-replace-")
payload = _json_out(run(CREDITS, ["update", "--json", "--id", "1", "--value", "175.00",
"--description", "175 voucher (revised)"], home))
assert set(payload["fields_changed"]) == {"Value", "Description"}, payload
credit = _json_out(run(CREDITS, ["list", "--json"], home))["credits"][0]
assert credit["value"] == "175.00" and credit["description"] == "175 voucher (revised)"
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1, \
"update must not duplicate the record"
def test_update_preserves_fields_the_formatter_does_not_know():
"""A text-level edit, for the same reason the migration is one."""
home = _voucher_home("update-preserve-")
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
text = fh.read()
with open(inventory, "w") as fh:
fh.write(text.replace("- **Added**:", "- **Unknown legacy field**: keep me\n- **Added**:"))
run(CREDITS, ["update", "--json", "--id", "1", "--value", "160.00"], home)
with open(inventory) as fh:
after = fh.read()
assert "- **Unknown legacy field**: keep me" in after, f"field dropped:\n{after}"
def test_update_rejects_an_unknown_id():
home = _voucher_home("update-unknown-")
r = run(CREDITS, ["update", "--json", "--id", "99", "--value", "1.00"], home)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "not_found" and payload["id"] == 99, payload
assert "99" in r.stderr, "the message must name the id"
def test_update_names_the_archive_rather_than_reporting_not_found():
"""A settled record is sitting right there; "not found" would send the caller hunting."""
home = _voucher_home("update-archived-")
assert run(CREDITS, ["use", "--json", "--id", "1", "--note", "used"], home).returncode == 0
r = run(CREDITS, ["update", "--json", "--id", "1", "--value", "5.00"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "record_is_archived", r.stdout
def test_update_validates_the_expiry_before_writing():
home = _voucher_home("update-badexpiry-")
inventory = os.path.join(home, ".claude", "travel-credits", "inventory.md")
with open(inventory) as fh:
before = fh.read()
r = run(CREDITS, ["update", "--json", "--id", "1", "--expiry", "June 30"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "invalid_expiry", r.stdout
with open(inventory) as fh:
assert fh.read() == before, "a rejected update must not touch the store"
def test_update_requires_at_least_one_field():
home = _voucher_home("update-nofields-")
r = run(CREDITS, ["update", "--json", "--id", "1"], home)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "no_fields_given", payload
assert "description" in payload["updatable"], payload
def test_update_reaches_a_deposit_but_still_refuses_an_expiry():
"""Deposits are current records too — a case number can arrive late for them as well."""
home = _mktemp("update-deposit-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--json", "--type", "MILES", "--desc", "8,000 SkyMiles",
"--value", "8000 miles", "--airline", "DL"], home)
payload = _json_out(run(CREDITS, ["update", "--json", "--id", "1",
"--confirmation", "Case 19912032"], home))
assert payload["updated"]["section"] == "compensation", payload
assert _json_out(run(CREDITS, ["history", "--json"], home))["deposits"][0]["confirmation"] \
== "Case 19912032"
r = run(CREDITS, ["update", "--json", "--id", "1", "--expiry", "2024-01-01"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "expiry_not_valid_for_deposit", r.stdout
# ── the clock seam ────────────────────────────────────────────────────────────
def test_expiry_decisions_read_the_injected_reference_not_the_wall_clock():
"""The seam every date-sensitive fixture depends on.
Without it a fixture has to pin a date ahead of the real clock, which only moves
the deadline — it starts failing on its own once the clock passes it.
"""
home = _mktemp("clock-seam-")
run(CREDITS, ["init", "--default"], home)
run(CREDITS, ["add", "--json", "--type", "ECREDIT", "--desc", "Expires mid-2026",
"--value", "100.00", "--expiry", "2026-06-15"], home)
# Frozen before the expiry: live, and inside a 120-day window.
early = _json_out(run(CREDITS, ["expiring", "--json", "--days", "120"], home,
today="2026-03-01"))
assert [e["id"] for e in early["expiring"]] == [1], early
assert early["as_of"] == "2026-03-01", "the payload must report the injected date"
assert early["expiring"][0]["expired"] is False, early
assert early["expiring"][0]["days_left"] > 0, early
# Frozen after it: the same record now reads as expired against the same window.
late = _json_out(run(CREDITS, ["expiring", "--json", "--days", "120"], home,
today="2026-07-01"))
assert late["as_of"] == "2026-07-01", late
assert late["expiring"][0]["expired"] is True, late
assert late["expiring"][0]["days_left"] < 0, late
listed = _json_out(run(CREDITS, ["list", "--json"], home, today="2026-07-01"))
assert listed["credits"][0]["expired"] is True, listed
assert _json_out(run(CREDITS, ["list", "--json"], home,
today="2026-03-01"))["credits"][0]["expired"] is False
def test_a_malformed_reference_date_is_fatal_not_ignored():
"""Falling back to the wall clock would let a suite that meant to freeze time
run against the real one and pass for the wrong reason."""
home = _mktemp("clock-bad-")
run(CREDITS, ["init", "--default"], home)
r = run(CREDITS, ["list", "--json"], home, today="March 2026")
assert r.returncode == 2, f"expected a hard exit, got {r.returncode}: {r.stdout}{r.stderr}"
assert "CREDITS_TRACKER_TODAY" in r.stderr, r.stderr
assert "not be ignored" in r.stderr, r.stderr
# The failure still owes the caller one JSON object — an empty stdout reads as a
# crash, which is the contract 0.9.27 established for every other failure path.
payload = _json_out(r)
assert payload["error"] == "invalid_reference_date", payload
assert payload["given"] == "March 2026", payload
# An explicitly empty value is a misconfiguration, not an absence. Falling back to
# the wall clock there would be the silent fallback this whole guard refuses.
empty = run(CREDITS, ["list", "--json"], home, today="")
assert empty.returncode == 2, f"an empty override must not read as unset: {empty.stdout}"
assert _json_out(empty)["error"] == "invalid_reference_date", empty.stdout
# And it must not break --help, which needs no reference date at all.
helped = run(CREDITS, ["--help"], home, today="March 2026")
assert helped.returncode == 0, f"--help must not require a valid override:\n{helped.stderr}"
def test_the_reference_date_falls_through_to_the_module_clock_when_unset():
"""Unset is the production path — it must reach the clock, not a frozen default.
Asserted against a stubbed clock rather than the real one: comparing to
`date.today()` at assertion time is itself a wall-clock dependency, and it races
the subprocess across midnight.
"""
import datetime
class _FixedClock:
@staticmethod
def now():
return datetime.datetime(2019, 7, 4, 12, 0, 0)
@staticmethod
def strptime(value, fmt):
return datetime.datetime.strptime(value, fmt)
tracker = _load_tracker()
setattr(tracker, "datetime", _FixedClock)
saved = os.environ.pop(tracker.TODAY_ENV, None)
try:
assert tracker.reference_date() == datetime.date(2019, 7, 4), \
"unset must fall through to the module's clock"
# And the override still wins over it.
os.environ[tracker.TODAY_ENV] = "2020-01-02"
assert tracker.reference_date() == datetime.date(2020, 1, 2)
finally:
os.environ.pop(tracker.TODAY_ENV, None)
if saved is not None:
os.environ[tracker.TODAY_ENV] = saved
# ── line-oriented store: no value may become structure ────────────────────────
def test_a_newline_in_a_value_cannot_inject_record_structure():
"""The record format is line-oriented, so a newline in a value becomes structure.
Demonstrated before the guard existed: `update --description` carrying a `### #99`
line spliced in a whole record and the store listed id 99 in place of id 1, and
`add --value` carrying an `Expiry` line wrote an expiry the caller never passed.
"""
home = _voucher_home("inject-")
injections = [
(["update", "--json", "--id", "1", "--description",
"Pwned\n\n### #99 — [ECREDIT] Injected\n- **Value**: 99999.00"], "description"),
(["update", "--json", "--id", "1", "--value", "5.00\n- **Expiry**: 2024-01-01"], "value"),
(["add", "--json", "--type", "VOUCHER", "--desc", "Second",
"--value", "5.00\n- **Expiry**: 2024-01-01"], "value"),
(["add", "--json", "--type", "VOUCHER", "--desc",
"X\n### #98 — [ECREDIT] Injected", "--value", "1.00"], "description"),
(["use", "--json", "--id", "1", "--note", "ok\n- **Value**: 0"], "note"),
]
for argv, field in injections:
r = run(CREDITS, argv, home)
assert r.returncode != 0, f"{argv[0]} accepted a newline in --{field}: {r.stdout}"
payload = _json_out(r)
assert payload["error"] == "multiline_value", payload
assert payload["fields"] == [field], payload
listed = _json_out(run(CREDITS, ["list", "--json"], home))
assert [c["id"] for c in listed["credits"]] == [1], f"the store must be untouched: {listed}"
assert listed["credits"][0]["description"] == "150 voucher"
assert "expiry" not in listed["credits"][0], "no field the caller never passed"
def test_a_carriage_return_is_rejected_too():
"""A lone CR splits a line in the same way; the guard covers both."""
home = _voucher_home("inject-cr-")
r = run(CREDITS, ["update", "--json", "--id", "1",
"--confirmation", "ABC\r- **Value**: 0"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "multiline_value", r.stdout
# ── compensation deposits: history, not inventory ─────────────────────────────
_V1_STORE_WITH_DEPOSITS = """# Flight Credits, Vouchers & Upgrade Certificates Inventory
## Active Credits
<!-- CREDITS_START — do not edit this marker -->
### #1 — [COMP] 25,000 SkyMiles goodwill (Case 18758214)
- **Schema version**: 1
- **Value**: 25000 miles
- **Airline**: DL
- **Confirmation**: Case 18758214
- **Added**: 2026-03-01
- **Unknown field**: preserve me
### #2 — [COMP] Delta Reserve companion cert 2026
- **Schema version**: 1
- **Value**: 1 certificate
- **Expiry**: 2024-01-31
- **Airline**: DL
- **Added**: 2026-01-15
### #3 — [COMP] 30,000 Hilton Honors points goodwill
- **Schema version**: 1
- **Value**: 30,000 Hilton Honors points
- **Brand**: HILTON
- **Added**: 2026-05-02
### #4 — [ECREDIT] Canceled BNA-JFK
- **Schema version**: 1
- **Value**: 347.20
- **Expiry**: 2024-12-15
- **Airline**: DL
- **Added**: 2026-02-01
<!-- CREDITS_END — do not edit this marker -->
## Used/Expired Credits (Archive)
<!-- ARCHIVE_START — do not edit this marker -->
<!-- ARCHIVE_END — do not edit this marker -->
"""
def _v1_store_home(prefix):
"""A pre-v2 store with two miles/points grants mistyped COMP, plus a real cert."""
home = _mktemp(prefix)
store = os.path.join(home, ".claude", "travel-credits")
os.makedirs(store)
with open(os.path.join(store, "inventory.md"), "w") as fh:
fh.write(_V1_STORE_WITH_DEPOSITS)
return home
def test_migration_moves_miles_and_points_grants_out_of_inventory():
"""Deposits have no held-then-applied lifecycle, so they are not inventory.
An airline granting 25,000 miles deposits them on the spot. Sitting in Active
they were counted as available forever, and `use` was the only exit — asserting
an application event that never happened.
"""
home = _v1_store_home("deposits-migrate-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
moved = {m["id"]: m["to_type"] for m in payload["deposits_relocated"]}
assert moved == {1: "MILES", 3: "POINTS"}, payload
assert payload["unconsumable"] == 0, payload
listed = _json_out(run(CREDITS, ["list", "--json"], home))
assert [c["id"] for c in listed["credits"]] == [2, 4], \
f"deposits must leave the available set: {listed}"
history = _json_out(run(CREDITS, ["history", "--json"], home))
assert [d["id"] for d in history["deposits"]] == [1, 3], history
def test_deposit_classification_covers_the_value_shapes_the_store_uses():
"""Every unit shape seen in the live store, and the non-deposits it must not touch.
An earlier pattern allowed at most one word between the amount and the unit, so
"30,000 Hilton Honors points" — taken straight from the store — stayed in Active.
The first fixture happened to use the one-word variant and passed anyway, which is
a test written against the implementation rather than the requirement.
"""
tracker = _load_tracker()
deposits = {
"25000 miles": "MILES",
"30,000 Honors points": "POINTS",
"30,000 Hilton Honors points": "POINTS",
"8,000 SkyMiles": "MILES",
"25,000 American AAdvantage miles": "MILES",
"5,000 AAdvantage miles": "MILES",
}
for value, unit in deposits.items():
got = tracker.deposit_unit([f"- **Value**: {value}"])
assert got == unit, f"{value!r} classified {got!r}, expected {unit!r}"
# A false positive moves a genuine credit out of the available set, so these matter
# more than the misses: none of them may classify as a deposit. The last two are why
# the pattern is anchored at both ends — unanchored, each contained a unit and matched.
for value in ["1 certificate", "347.20", "$200.00", "2 nights", "1 upgrade certificate",
"5000 miles voucher", "1 certificate for 5000 miles travel"]:
got = tracker.deposit_unit([f"- **Value**: {value}"])
assert got is None, f"{value!r} must not be treated as a deposit, got {got!r}"
def test_migration_relocates_a_deposit_logged_under_any_type():
"""Classification is by Value, not by the type the record happens to carry.
Keying on COMP alone stranded every deposit logged under another type — and the
skill's only worked example was `--type VOUCHER`, so those records exist.
"""
home = _mktemp("deposits-anytype-")
store = os.path.join(home, ".claude", "travel-credits")
os.makedirs(store)
with open(os.path.join(store, "inventory.md"), "w") as fh:
fh.write(_V1_STORE_WITH_DEPOSITS.replace(
"### #4 — [ECREDIT] Canceled BNA-JFK\n"
"- **Schema version**: 1\n"
"- **Value**: 347.20",
"### #4 — [VOUCHER] 12,000 SkyMiles goodwill\n"
"- **Schema version**: 1\n"
"- **Value**: 12,000 SkyMiles"))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
moved = {m["id"]: (m["from_type"], m["to_type"]) for m in payload["deposits_relocated"]}
assert moved.get(4) == ("VOUCHER", "MILES"), f"a VOUCHER-typed deposit must move: {payload}"
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 1, "only the cert stays"
def test_migration_leaves_a_newer_miles_record_where_it_is():
"""A record this reader cannot read must not be relocated — a move is a rewrite.
Stamping already leaves a newer record alone. Relocation ran over every active
record afterwards, so a version-99 miles row would have been moved and retyped by
a reader with no idea what shape it is in.
"""
home = _mktemp("deposits-newer-")
store = os.path.join(home, ".claude", "travel-credits")
os.makedirs(store)
with open(os.path.join(store, "inventory.md"), "w") as fh:
fh.write(_V1_STORE_WITH_DEPOSITS.replace(
"### #1 — [COMP] 25,000 SkyMiles goodwill (Case 18758214)\n"
"- **Schema version**: 1",
"### #1 — [COMP] 25,000 SkyMiles goodwill (Case 18758214)\n"
"- **Schema version**: 99"))
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert payload["skipped_newer"] == 1, payload
assert 1 not in [m["id"] for m in payload["deposits_relocated"]], \
f"a newer record must not be relocated: {payload}"
with open(os.path.join(store, "inventory.md")) as fh:
after = fh.read()
active = after.split("<!-- CREDITS_START")[1].split("<!-- CREDITS_END")[0]
assert "### #1 — [COMP]" in active, f"it must stay put, untyped and unmoved:\n{active}"
assert "- **Schema version**: 99" in active, "and keep its own version"
def test_migration_renames_comp_to_companion():
"""`COMP` reads as "compensation" and means Companion Certificate.
Nothing rejected the misreading, and every COMP row in the live store turned out to
be a mistyped miles or points grant. Those move out on their Value; what remains is
renamed so the next reader cannot make the same mistake.
"""
home = _v1_store_home("rename-comp-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
renamed = {r["id"]: (r["from_type"], r["to_type"]) for r in payload["types_renamed"]}
assert renamed == {2: ("COMP", "COMPANION")}, (
f"only the genuine certificate is renamed; the deposits left on their Value: {payload}")
listed = _json_out(run(CREDITS, ["list", "--json"], home))
types = {c["id"]: c["type"] for c in listed["credits"]}
assert types.get(2) == "COMPANION", types
assert "COMP" not in types.values(), f"no COMP may survive the migration: {types}"
def test_a_relocated_deposit_is_not_also_renamed():
"""Order matters: a COMP row valued in miles leaves on its Value, unlabelled.
Renaming before relocating would have stamped COMPANION onto a miles deposit on its
way out — the exact mislabel this rename exists to end.
"""
home = _v1_store_home("rename-order-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
renamed_ids = {r["id"] for r in payload["types_renamed"]}
moved_ids = {m["id"] for m in payload["deposits_relocated"]}
assert not (renamed_ids & moved_ids), f"a record must not be both: {payload}"
history = _json_out(run(CREDITS, ["history", "--json"], home))
assert {d["type"] for d in history["deposits"]} == {"MILES", "POINTS"}, history
def test_the_retired_type_is_rejected_with_its_replacement():
"""A retired token gets a message naming what replaced it, not a bare invalid-type."""
home = _mktemp("rename-retired-")
run(CREDITS, ["init", "--default"], home)
r = run(CREDITS, ["add", "--json", "--type", "COMP", "--desc", "Something",
"--value", "1 certificate"], home)
assert r.returncode != 0
payload = _json_out(r)
assert payload["error"] == "retired_type", payload
assert payload["renamed_to"] == "COMPANION", payload
# The point of the message: say where a miles grant should actually go.
assert "MILES" in r.stderr and "POINTS" in r.stderr, r.stderr
def test_migration_leaves_a_genuine_companion_certificate_in_place():
"""Classification reads the Value field's unit, not the description's prose."""
home = _v1_store_home("deposits-keep-")
run(CREDITS, ["migrate", "--json"], home)
listed = _json_out(run(CREDITS, ["list", "--json"], home))
cert = [c for c in listed["credits"] if c["id"] == 2]
assert cert and cert[0]["type"] == "COMPANION", \
f"a real companion cert must stay, renamed: {listed}"
def test_migration_preserves_fields_the_formatter_does_not_know():
"""A relocated record moves verbatim apart from its type token."""
home = _v1_store_home("deposits-preserve-")
run(CREDITS, ["migrate", "--json"], home)
with open(os.path.join(home, ".claude", "travel-credits", "inventory.md")) as fh:
after = fh.read()
assert "- **Unknown field**: preserve me" in after, f"field dropped:\n{after}"
assert "Case 18758214" in after
def test_deposits_are_excluded_from_matching_expiry_and_the_total():
"""Never available inventory: not matched, not a deadline, not money on hand."""
home = _v1_store_home("deposits-excluded-")
run(CREDITS, ["migrate", "--json"], home)
checked = _json_out(run(CREDITS, ["check", "--json", "--scenario",
"round-trip domestic DL"], home))
assert 1 not in [m["id"] for m in checked["matches"]], \
f"a miles deposit must not be offered as bookable: {checked}"
expiring = _json_out(run(CREDITS, ["expiring", "--json"], home))
assert 1 not in [e["id"] for e in expiring["expiring"]], expiring
assert 1 not in [e["id"] for e in expiring["no_expiry"]], \
"a deposit is not an undated credit — it is not a credit"
summary = _json_out(run(CREDITS, ["summary", "--json"], home))
assert summary["total_monetary_value"] == 347.20, \
f"deposits must not count as available value: {summary}"
def test_a_deposit_has_no_use_transition():
"""`use` on a deposit asserts an event that never happened — refuse it."""
home = _v1_store_home("deposits-nouse-")
run(CREDITS, ["migrate", "--json"], home)
r = run(CREDITS, ["use", "--json", "--id", "1", "--note", "spent them"], home)
assert r.returncode != 0, r.stdout
assert _json_out(r)["error"] == "deposit_has_no_use_transition", r.stdout
history = _json_out(run(CREDITS, ["history", "--json"], home))
assert [d["id"] for d in history["deposits"]] == [1, 3], "the record must survive intact"
def test_adding_a_deposit_writes_to_history_not_inventory():
home = _mktemp("deposits-add-")
run(CREDITS, ["init", "--default"], home)
assert run(CREDITS, ["add", "--json", "--type", "MILES", "--desc", "8,000 SkyMiles goodwill",
"--value", "8000 miles", "--airline", "DL"], home).returncode == 0
assert _json_out(run(CREDITS, ["list", "--json"], home))["count"] == 0
assert _json_out(run(CREDITS, ["history", "--json"], home))["count"] == 1
def test_a_deposit_rejects_an_expiry():
"""A deposit is in the account already; a deadline here would be unenforceable."""
home = _mktemp("deposits-expiry-")
run(CREDITS, ["init", "--default"], home)
r = run(CREDITS, ["add", "--json", "--type", "POINTS", "--desc", "Goodwill points",
"--value", "5000 points", "--expiry", "2024-01-01"], home)
assert r.returncode != 0
assert _json_out(r)["error"] == "expiry_not_valid_for_deposit", r.stdout
def test_migration_adds_the_compensation_section_to_an_older_store():
"""A store written before the section existed gains it without losing anything."""
home = _v1_store_home("deposits-section-")
with open(os.path.join(home, ".claude", "travel-credits", "inventory.md")) as fh:
assert "COMPENSATION_START" not in fh.read()
run(CREDITS, ["migrate", "--json"], home)
with open(os.path.join(home, ".claude", "travel-credits", "inventory.md")) as fh:
after = fh.read()
assert "COMPENSATION_START" in after and "COMPENSATION_END" in after
assert "Canceled BNA-JFK" in after, "untouched records must survive the section append"
def test_the_advocate_skill_reads_history_for_prior_compensation():
"""Step 4's prior-compensation check must reach deposits, not only Active.
Deposits leave Active in v2, so a Step 4 that ran `list` alone would report "no
prior compensation" for a passenger the airline has already paid off — throwing
away the strongest leverage the letter has.
"""
skill = os.path.normpath(os.path.join(HERE, "..", "SKILL.md"))
with open(skill, encoding="utf-8") as fh:
text = fh.read()
step4 = text.split("## Step 4 —")[1].split("\n## ")[0]
assert 'Skill(skill: "using-travel-credits")' in step4, \
"Step 4 must read through the owner skill, which migrates before it reads"
invocations = [ln for ln in step4.split("\n")
if "python3 " in ln and "credits-tracker.py" in ln]
assert not invocations, (
"a direct read here is a non-owner read: un-migrated records are skipped and the "
f"count: 0 gets recorded as evidence of no prior compensation — {invocations}")
assert "history" in step4, "it must reach deposits, not only the active list"
def test_an_unknown_section_name_fails_loudly():
"""The old two-section form defaulted anything not "active" to the archive."""
tracker = _load_tracker()
try:
tracker.section_markers("activ")
except ValueError as exc:
assert "unknown section" in str(exc), exc
else:
raise AssertionError("a mistyped section name must not silently resolve")
# ── using-travel-credits router contract ──────────────────────────────────────
ROUTER_SKILL = os.path.normpath(
os.path.join(HERE, "..", "..", "using-travel-credits", "SKILL.md"))
# Types the router must not spell out — the accepted set is the script's.
_SCRIPT_OWNED_TYPES = ["GUC", "RUC", "ECREDIT", "PARTNER", "AMEX"]
def _router_invocations():
"""Every line in the router's SKILL.md that runs the tracker."""
with open(ROUTER_SKILL, encoding="utf-8") as fh:
lines = fh.read().split("\n")
return [ln for ln in lines if "credits-tracker.py" in ln and "python3 " in ln]
def test_the_advocate_skill_does_not_restate_the_type_vocabulary():
"""script-as-black-box: the accepted --type set belongs to the script.
The skill needs an agent to stop guessing a type from an abbreviation, and the
durable way to do that is the script's own `retired_type` / `invalid_type` payloads
plus `--help` — not a copied list that drifts out of date silently.
"""
skill = os.path.normpath(os.path.join(HERE, "..", "SKILL.md"))
with open(skill, encoding="utf-8") as fh:
text = fh.read()
leaked = [tok for tok in _SCRIPT_OWNED_TYPES + ["COMPANION", "MILES", "POINTS"]
if tok in text]
assert not leaked, f"SKILL.md restates the script's type vocabulary: {leaked}"
assert "--help" in text, "it must point at --help for the accepted set"
def test_router_invocations_use_the_plugin_mount_path():
# skill-authoring Script References: one path convention per SKILL.md, and it must be
# the one that resolves where the skill is invoked. A consumer copies these verbatim.
mount = (".tessl/plugins/jbaruch/frequent-flyer-advocate"
"/skills/frequent-flyer-advocate/scripts/credits-tracker.py")
invocations = _router_invocations()
assert invocations, f"no tracker invocations found in {ROUTER_SKILL}"
for line in invocations:
assert mount in line, f"invocation does not use the mount path:\n {line.strip()}"
def test_router_always_passes_json():
# script-delegation Script Requirements: a skill-invoked deterministic script is
# JSON-producing. The prose rendering is the interactive human path; an agent that
# scrapes it re-introduces the table-parsing the --json contract exists to end.
for line in _router_invocations():
assert "--json" in line, f"router invocation omits --json:\n {line.strip()}"
def test_router_does_not_restate_the_script_type_vocabulary():
# script-as-black-box: the accepted --type set belongs to the script. Copied into the
# skill it drifts, and a stale list is how an agent picks a type the script rejects.
with open(ROUTER_SKILL, encoding="utf-8") as fh:
text = fh.read()
leaked = [t for t in _SCRIPT_OWNED_TYPES if t in text]
assert not leaked, f"router restates the script's type vocabulary: {leaked}"
assert "--help" in text, "router must point at --help for the accepted type set"
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for t in tests:
try:
t()
print(f"PASS {t.__name__}")
except AssertionError as e:
failed += 1
print(f"FAIL {t.__name__}: {e}")
print(f"\n{len(tests) - failed}/{len(tests)} passed")
return 1 if failed else 0
# A pre-v2 store whose miles grants sit in the ARCHIVE, already marked used. v2
# scanned Active only, so these were never classified by Value and v3's rename
# turned them into COMPANION — the shape found in the live store, reproduced here
# with its real values (25,000 and 8,000 SkyMiles).
_V1_STORE_ARCHIVED_DEPOSITS = """# Travel Credits Inventory
## Active Credits
<!-- CREDITS_START — do not edit this marker -->
### #9 — [ECREDIT] Canceled BNA-JFK
- **Schema version**: 1
- **Value**: 347.20
- **Expiry**: 2024-12-15
- **Airline**: DL
- **Added**: 2026-02-01
<!-- CREDITS_END — do not edit this marker -->
## Used/Expired Credits (Archive)
<!-- ARCHIVE_START — do not edit this marker -->
### #1 — [COMP] 25,000 SkyMiles goodwill bonus — DL5997 LHR-JFK
- **Schema version**: 1
- **Value**: 25000 miles
- **Passenger**: Baruch Sadogursky
- **Airline**: DL
- **Added**: 2026-03-01
- **Used date**: 2026-08-09
- **Used note**: Reported used by Baruch 2026-08-09
### #3 — [COMP] Delta Reserve companion cert 2026
- **Schema version**: 1
- **Value**: 1 certificate
- **Passenger**: Baruch Sadogursky
- **Airline**: DL
- **Added**: 2026-03-01
- **Used date**: 2026-08-09
<!-- ARCHIVE_END — do not edit this marker -->
"""
def _archived_deposit_store_home(prefix):
home = _mktemp(prefix)
store = os.path.join(home, ".claude", "travel-credits")
os.makedirs(store)
with open(os.path.join(store, "inventory.md"), "w") as fh:
fh.write(_V1_STORE_ARCHIVED_DEPOSITS)
return home
def test_migration_relocates_a_miles_grant_stranded_in_the_archive():
"""v2 scanned Active only, so an archived grant fell through to v3's rename.
A grant is in the loyalty account from the moment it is made, so an archived
one records a use that cannot happen — and it landed as COMPANION, which v3's
own note says no COMP row deserved.
"""
home = _archived_deposit_store_home("archived-deposit-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
relocated = {e["id"]: e for e in payload["archived_deposits_relocated"]}
assert 1 in relocated, payload
assert relocated[1]["to_type"] == "MILES", relocated[1]
assert payload["unconsumable"] == 0, payload
deposits = _json_out(run(CREDITS, ["history", "--json"], home))["deposits"]
assert [d["id"] for d in deposits] == [1], deposits
assert deposits[0]["value"] == "25000 miles", deposits[0]
def test_a_real_companion_cert_stays_in_the_archive():
"""Value is the classifier, not the type token. "1 certificate" is not a grant."""
home = _archived_deposit_store_home("archived-cert-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert 3 not in {e["id"] for e in payload["archived_deposits_relocated"]}, payload
assert {e["id"]: e["to_type"] for e in payload["types_renamed"]}[3] == "COMPANION", payload
assert 3 not in {d["id"] for d in _json_out(run(CREDITS, ["history", "--json"], home))["deposits"]}
def test_relocating_an_archived_grant_sheds_its_used_state():
"""A compensation record has no used state, so the marking cannot ride along.
Leaving it would assert that miles sitting in the loyalty account had been
spent — the same false reading the relocation exists to correct.
"""
home = _archived_deposit_store_home("archived-strip-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
with open(os.path.join(home, ".claude", "travel-credits", "inventory.md")) as fh:
after = fh.read()
assert "Reported used by Baruch 2026-08-09" not in after, after
assert "**Used date**: 2026-08-09\n- **Passenger**: Baruch Sadogursky" not in after
# #3 is a genuine cert that stays archived and keeps its own used date.
assert "**Used date**" in after, "the untouched archive row lost its used state"
def test_the_shed_used_state_is_reported_not_silently_dropped():
"""Removing a field the operator wrote is auditable or it is data loss."""
home = _archived_deposit_store_home("archived-report-")
payload = _json_out(run(CREDITS, ["migrate", "--json"], home))
entry = {e["id"]: e for e in payload["archived_deposits_relocated"]}[1]
assert entry["dropped_used_state"] == {
"Used date": "2026-08-09",
"Used note": "Reported used by Baruch 2026-08-09",
}, entry
def test_archived_grant_never_reappears_as_a_spendable_credit():
"""The bug's user-visible cost: it read as inventory and got marked used."""
home = _archived_deposit_store_home("archived-not-active-")
run(CREDITS, ["migrate", "--json"], home)
active = _json_out(run(CREDITS, ["list", "--json"], home))
assert [c["id"] for c in active["credits"]] == [9], active
def test_archive_relocation_is_idempotent():
home = _archived_deposit_store_home("archived-idem-")
run(CREDITS, ["migrate", "--json"], home)
second = _json_out(run(CREDITS, ["migrate", "--json"], home))
assert second["changed"] is False, second
assert second["archived_deposits_relocated"] == [], second
if __name__ == "__main__":
sys.exit(main())