patx/mongokv

import os
import uuid

import pytest
import pytest_asyncio

from mongokv import Mkv

MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017")


# ---------- Fixtures ----------

@pytest_asyncio.fixture
async def mkv():
    collection_name = f"test_kv_{uuid.uuid4().hex}"
    db = Mkv(MONGO_URI, db_name="test_mkvdb", collection_name=collection_name)

    try:
        await db.db.command("ping")
    except Exception:
        pytest.skip(f"MongoDB not available at {MONGO_URI}")

    await db.purge()

    try:
        yield db
    finally:
        # Best-effort cleanup; don't let errors fail the test
        try:
            await db.purge()
        except Exception:
            pass
        try:
            await db.close()
        except Exception:
            pass


@pytest.fixture
def mkv_sync():
    collection_name = f"test_kv_sync_{uuid.uuid4().hex}"
    db = Mkv(MONGO_URI, db_name="test_mkvdb", collection_name=collection_name)

    try:
        db.purge()
    except Exception:
        pass

    try:
        yield db
    finally:
        try:
            db.purge()
        except Exception:
            pass
        try:
            db.close()
        except Exception:
            pass


# ---------- Async tests ----------

@pytest.mark.asyncio
async def test_set_and_get_value_async(mkv: Mkv):
    await mkv.set("foo", "bar")
    value = await mkv.get("foo")
    assert value == "bar"


@pytest.mark.asyncio
async def test_get_missing_returns_default_async(mkv: Mkv):
    value = await mkv.get("missing", default=123)
    assert value == 123


@pytest.mark.asyncio
async def test_get_missing_default_none_async(mkv: Mkv):
    with pytest.raises(KeyError):
        await mkv.get("missing")


@pytest.mark.asyncio
async def test_overwrite_existing_key_async(mkv: Mkv):
    await mkv.set("counter", 1)
    await mkv.set("counter", 2)
    value = await mkv.get("counter")
    assert value == 2


@pytest.mark.asyncio
async def test_remove_existing_key_async(mkv: Mkv):
    await mkv.set("temp", "value")
    removed = await mkv.remove("temp")
    assert removed is True

    # Confirm it’s gone
    with pytest.raises(KeyError):
        await mkv.get("temp")


@pytest.mark.asyncio
async def test_remove_missing_key_returns_false_async(mkv: Mkv):
    removed = await mkv.remove("does-not-exist")
    assert removed is False


@pytest.mark.asyncio
async def test_all_returns_all_keys_async(mkv: Mkv):
    await mkv.set("k1", "v1")
    await mkv.set("k2", "v2")
    await mkv.set("k3", "v3")

    keys = await mkv.all()
    # Order is not guaranteed, so assert as a set
    assert set(keys) == {"k1", "k2", "k3"}


@pytest.mark.asyncio
async def test_all_on_empty_db_returns_empty_list_async(mkv: Mkv):
    keys = await mkv.all()
    assert keys == []


@pytest.mark.asyncio
async def test_purge_clears_all_keys_async(mkv: Mkv):
    await mkv.set("a", 1)
    await mkv.set("b", 2)

    keys_before = await mkv.all()
    assert set(keys_before) == {"a", "b"}

    result = await mkv.purge()
    assert result is True

    keys_after = await mkv.all()
    assert keys_after == []


@pytest.mark.asyncio
async def test_non_string_keys_are_cast_to_str_async(mkv: Mkv):
    await mkv.set(123, "number")
    await mkv.set(("tuple", 1), "tuple-value")

    # We expect them to be stored under str(key)
    keys = await mkv.all()
    assert set(keys) == {"123", "('tuple', 1)"}

    assert await mkv.get(123) == "number"
    assert await mkv.get(("tuple", 1)) == "tuple-value"


@pytest.mark.asyncio
async def test_close_does_not_throw_async(mkv: Mkv):
    # The fixture already closes in teardown, but we can call it early too.
    await mkv.set("k", "v")
    await mkv.close()  # Should not raise
    # Don't assert behavior after close (Motor generally allows it but it's not required)


@pytest.mark.asyncio
async def test_get_missing_default_none_explicit_async(mkv: Mkv):
    value = await mkv.get("missing", default=None)
    assert value is None


@pytest.mark.asyncio
async def test_get_missing_raises_keyerror_async(mkv: Mkv):
    with pytest.raises(KeyError):
        await mkv.get("missing")
        
        
# ---------- Sync tests (dualmethod behavior) ----------

def test_sync_get_missing_raises_keyerror(mkv_sync: Mkv):
    with pytest.raises(KeyError):
        mkv_sync.get("missing")

def test_sync_get_missing_default_none_explicit(mkv_sync: Mkv):
    value = mkv_sync.get("missing", default=None)
    assert value is None
    

def test_sync_set_and_get(mkv_sync: Mkv):
    mkv_sync.set("foo", "bar")
    value = mkv_sync.get("foo")
    assert value == "bar"


def test_sync_get_missing_returns_default(mkv_sync: Mkv):
    value = mkv_sync.get("nope", default="fallback")
    assert value == "fallback"


def test_sync_remove_and_purge(mkv_sync: Mkv):
    mkv_sync.set("a", 1)
    mkv_sync.set("b", 2)

    removed = mkv_sync.remove("a")
    assert removed is True

    with pytest.raises(KeyError):
        mkv_sync.get("a")

    assert mkv_sync.get("b") == 2

    purged = mkv_sync.purge()
    assert purged is True
    assert mkv_sync.all() == []


def test_sync_non_string_keys_cast_to_str(mkv_sync: Mkv):
    mkv_sync.set(10, "ten")
    mkv_sync.set(("x", "y"), {"ok": True})

    keys = mkv_sync.all()
    assert set(keys) == {"10", "('x', 'y')"}

    assert mkv_sync.get(10) == "ten"
    assert mkv_sync.get(("x", "y")) == {"ok": True}


def test_sync_close_does_not_raise(mkv_sync: Mkv):
    mkv_sync.set("k", "v")
    mkv_sync.close()  # Should not raise