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.
263 lines
7.4 KiB
Python
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
|