mud/tests/test_store.py
Jared Miller 1349c2f860
Add zone_name to persistence schema
Adds zone_name field to PlayerData and accounts table to track which
zone a player is in. Defaults to 'overworld'. Includes migration logic
to handle existing databases without the column. Server now resolves
zone from zone_name when loading player data.
2026-02-11 19:33:23 -05:00

263 lines
7.4 KiB
Python

"""Tests for the store (persistence) module."""
import os
import tempfile
from pathlib import Path
import pytest
from mudlib.player import Player
from mudlib.store import (
account_exists,
authenticate,
create_account,
init_db,
load_player_data,
save_player,
)
from mudlib.zone import Zone
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
init_db(db_path)
yield db_path
# Cleanup
os.unlink(db_path)
def test_init_db_creates_file(temp_db):
"""init_db creates the database file."""
assert Path(temp_db).exists()
def test_create_account_success(temp_db):
"""create_account creates a new account."""
assert create_account("Alice", "password123")
assert account_exists("Alice")
def test_create_account_case_insensitive(temp_db):
"""Account names are case-insensitive."""
create_account("Bob", "password123")
assert account_exists("bob")
assert account_exists("BOB")
assert account_exists("Bob")
def test_create_account_duplicate_fails(temp_db):
"""create_account fails if name is already taken."""
assert create_account("Charlie", "password123")
assert not create_account("Charlie", "different_password")
assert not create_account("charlie", "different_password") # case insensitive
def test_authenticate_success(temp_db):
"""authenticate returns True for correct password."""
create_account("Dave", "correct_password")
assert authenticate("Dave", "correct_password")
def test_authenticate_case_insensitive_name(temp_db):
"""authenticate works with case-insensitive names."""
create_account("Eve", "password123")
assert authenticate("eve", "password123")
assert authenticate("EVE", "password123")
def test_authenticate_wrong_password(temp_db):
"""authenticate returns False for wrong password."""
create_account("Frank", "correct_password")
assert not authenticate("Frank", "wrong_password")
def test_authenticate_nonexistent_account(temp_db):
"""authenticate returns False for nonexistent account."""
assert not authenticate("Ghost", "any_password")
def test_save_and_load_player_data(temp_db):
"""save_player and load_player_data persist player state."""
# Create account first
create_account("Grace", "password123")
# Create a player with non-default values
player = Player(
name="Grace",
x=42,
y=17,
pl=85.5,
stamina=60.0,
max_stamina=120.0,
flying=True,
)
# Save and load
save_player(player)
data = load_player_data("Grace")
assert data is not None
assert data["x"] == 42
assert data["y"] == 17
assert data["pl"] == 85.5
assert data["stamina"] == 60.0
assert data["max_stamina"] == 120.0
assert data["flying"] is True
def test_load_player_data_case_insensitive(temp_db):
"""load_player_data works with case-insensitive names."""
create_account("Henry", "password123")
player = Player(name="Henry", x=10, y=20)
save_player(player)
data_lower = load_player_data("henry")
data_upper = load_player_data("HENRY")
assert data_lower is not None
assert data_upper is not None
assert data_lower["x"] == 10
assert data_upper["x"] == 10
def test_load_player_data_nonexistent(temp_db):
"""load_player_data returns None for nonexistent account."""
assert load_player_data("Nobody") is None
def test_save_player_updates_existing(temp_db):
"""save_player updates existing player data."""
create_account("Iris", "password123")
# First save
player = Player(name="Iris", x=10, y=20, pl=100.0)
save_player(player)
# Update and save again
player.x = 50
player.y = 60
player.pl = 75.0
save_player(player)
# Load and verify
data = load_player_data("Iris")
assert data is not None
assert data["x"] == 50
assert data["y"] == 60
assert data["pl"] == 75.0
def test_default_values(temp_db):
"""New accounts have default values."""
create_account("Jack", "password123")
data = load_player_data("Jack")
assert data is not None
assert data["x"] == 0
assert data["y"] == 0
assert data["pl"] == 100.0
assert data["stamina"] == 100.0
assert data["max_stamina"] == 100.0
assert data["flying"] is False
def test_password_hashing_different_salts(temp_db):
"""Different accounts with same password have different hashes."""
create_account("Kate", "same_password")
create_account("Leo", "same_password")
# Both should authenticate
assert authenticate("Kate", "same_password")
assert authenticate("Leo", "same_password")
# This just verifies the API works correctly - we can't easily check
# the hashes are different without exposing internal details
def test_save_and_load_zone_name(temp_db):
"""save_player and load_player_data persist zone_name."""
create_account("Maria", "password123")
# Create a zone and player
zone = Zone(name="testzone", width=100, height=100)
player = Player(
name="Maria",
location=zone,
x=10,
y=20,
)
# Save and load
save_player(player)
data = load_player_data("Maria")
assert data is not None
assert data["zone_name"] == "testzone"
def test_default_zone_name(temp_db):
"""New accounts have zone_name default to 'overworld'."""
create_account("Noah", "password123")
data = load_player_data("Noah")
assert data is not None
assert data["zone_name"] == "overworld"
def test_zone_name_migration(temp_db):
"""Existing DB without zone_name column still works."""
# Create account, which will create default schema
create_account("Olivia", "password123")
# Simulate old DB by manually removing the zone_name column
import sqlite3
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
# Check if column exists and remove it
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
if "zone_name" in columns:
# SQLite doesn't support DROP COLUMN directly, so recreate table
cursor.execute("""
CREATE TABLE accounts_backup AS
SELECT name, password_hash, salt, x, y, pl, stamina,
max_stamina, flying, created_at, last_login
FROM accounts
""")
cursor.execute("DROP TABLE accounts")
cursor.execute("""
CREATE TABLE accounts (
name TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
pl REAL NOT NULL DEFAULT 100.0,
stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
)
""")
cursor.execute("""
INSERT INTO accounts
SELECT * FROM accounts_backup
""")
cursor.execute("DROP TABLE accounts_backup")
conn.commit()
conn.close()
# Now try to load player data - should handle missing column gracefully
data = load_player_data("Olivia")
assert data is not None
assert data["zone_name"] == "overworld" # Should default