Files
LatteandClaude Opus 4.8 61837d4783
CI / lint (pull_request) Successful in 49s
CI / test (pull_request) Successful in 56s
CI / lint (push) Successful in 50s
CI / publish (pull_request) Has been skipped
CI / test (push) Successful in 55s
CI / publish (push) Successful in 50s
Multi-character support across accounts
Rework the token store to hold many characters keyed by character_id,
migrating the previous single-character file transparently. `familiar login`
now adds a character; `familiar status` lists all; `familiar logout [name|id]`
removes one or all.

Every account tool gains an optional `character` (name or id): omitted means
all logged-in characters, given means just that one. Results are uniform
{"characters": [...]}. get_auth refreshes per character.

58 tests, coverage 89%. Closes #9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:47:34 +02:00

137 lines
4.7 KiB
Python

"""Authenticated account tools (auth mocked, ESI mocked), multi-character."""
import asyncio
import httpx
import pytest
from conftest import route
from familiar import account, auth
from familiar.server import mcp
CHARS = [
{"character_id": 99, "character_name": "Alpha", "scopes": []},
{"character_id": 100, "character_name": "Beta", "scopes": []},
]
@pytest.fixture
def fake_auth(monkeypatch):
monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS))
async def get_auth(cid, **kw):
return f"token-{cid}", cid
monkeypatch.setattr(auth, "get_auth", get_auth)
def resolve(identifier):
for c in CHARS:
if (
str(c["character_id"]) == str(identifier)
or (c["character_name"] or "").lower() == str(identifier).lower()
):
return c["character_id"]
raise auth.NotAuthenticated("no such character")
monkeypatch.setattr(auth, "resolve_character", resolve)
return CHARS
def test_wallet_all_characters(mock_esi, fake_auth):
mock_esi(lambda req: httpx.Response(200, json=1000.0))
result = asyncio.run(account.get_wallet())
assert len(result["characters"]) == 2
assert {c["character_name"] for c in result["characters"]} == {"Alpha", "Beta"}
assert all(c["balance"] == 1000.0 for c in result["characters"])
def test_wallet_single_character_by_name(mock_esi, fake_auth):
mock_esi(lambda req: httpx.Response(200, json=500.0))
result = asyncio.run(account.get_wallet(character="Alpha"))
assert len(result["characters"]) == 1
assert result["characters"][0]["character_id"] == 99
def test_unknown_character_raises(mock_esi, fake_auth):
mock_esi(lambda req: httpx.Response(200, json=1.0))
with pytest.raises(auth.NotAuthenticated):
asyncio.run(account.get_wallet(character="Ghost"))
def test_not_logged_in_raises(monkeypatch):
monkeypatch.setattr(auth, "list_characters", lambda: [])
with pytest.raises(auth.NotAuthenticated):
asyncio.run(account.get_wallet())
def test_skills_summary(mock_esi, fake_auth):
body = {"total_sp": 5_000_000, "unallocated_sp": 10_000, "skills": [{}, {}, {}]}
mock_esi(lambda req: route(req, {"/skills/": body}))
result = asyncio.run(account.get_skills(character=99))
assert result["characters"][0]["total_sp"] == 5_000_000
assert result["characters"][0]["skills_trained"] == 3
def test_skill_queue_limit(mock_esi, fake_auth):
queue = [{"skill_id": i, "finished_level": 5, "queue_position": i} for i in range(5)]
mock_esi(lambda req: route(req, {"/skillqueue/": queue}))
result = asyncio.run(account.get_skill_queue(character="Beta", limit=2))
entry = result["characters"][0]
assert entry["in_queue"] == 5
assert len(entry["upcoming"]) == 2
def test_location_merges_endpoints(mock_esi, fake_auth):
fixtures = {
"/location/": {"solar_system_id": 30000142, "station_id": 60003760},
"/ship/": {"ship_type_id": 670, "ship_name": "My Capsule"},
"/online/": {"online": True, "last_login": "2026-06-27T00:00:00Z"},
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(account.get_location(character=99))
entry = result["characters"][0]
assert entry["solar_system_id"] == 30000142
assert entry["ship_name"] == "My Capsule"
assert entry["online"] is True
def test_clones(mock_esi, fake_auth):
fixtures = {
"/clones/": {"home_location": {"location_id": 60003760}, "jump_clones": [{}, {}]},
"/implants/": [10000, 10001],
}
mock_esi(lambda req: route(req, fixtures))
result = asyncio.run(account.get_clones(character=99))
entry = result["characters"][0]
assert entry["jump_clones"] == 2
assert entry["active_implants"] == [10000, 10001]
def test_assets_paginates(mock_esi, fake_auth):
def handler(req):
page = req.url.params.get("page", "1")
type_id = 34 if page == "1" else 35
return httpx.Response(
200,
json=[{"type_id": type_id, "quantity": 1, "location_id": 1}],
headers={"X-Pages": "2"},
)
mock_esi(handler)
result = asyncio.run(account.get_assets(character=99, limit=20))
entry = result["characters"][0]
assert entry["total_stacks"] == 2
assert {a["type_id"] for a in entry["sample"]} == {34, 35}
def test_account_status_delegates(monkeypatch):
monkeypatch.setattr(auth, "status", lambda: {"authenticated": True, "characters": CHARS})
result = asyncio.run(account.account_status())
assert result["authenticated"] is True
assert len(result["characters"]) == 2
def test_account_tools_registered():
tools = {t.name for t in asyncio.run(mcp.list_tools())}
assert {"get_wallet", "get_skills", "get_location", "get_assets", "account_status"} <= tools