mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
fix(update): treat a timed paidAccess gate with no recorded end as active
CivitAI only returns a non-null paidAccess for an *active* gate: a lapsed gate
stays in the database as a tombstone and is filtered out server-side, so
{"permanent": false, "endsAt": null} — a timed gate whose window end has not
been recorded yet — is still enforced. Verified live: on model 1802980 that
version reports canDownload: false while its lapsed siblings report true.
Both the update service and the download gate dropped that shape, so such
versions read as free and "Hide Early Access Updates" missed them — the class
of bug reported in #1060.
The interpretation now lives in py/utils/paid_access.py and is shared, so the
badge, the update filter and the download warning cannot disagree.
This commit is contained in:
@@ -826,6 +826,32 @@ def test_extract_single_version_paid_access_permanent(tmp_path):
|
||||
assert version.paid_access is not None
|
||||
|
||||
|
||||
def test_extract_single_version_paid_access_pending_end(tmp_path):
|
||||
"""A timed gate whose window end is not recorded yet
|
||||
({"permanent": false, "endsAt": null}) is still an active gate, so it is
|
||||
early access with no known end date rather than a free version."""
|
||||
db_path = tmp_path / "updates.sqlite"
|
||||
service = ModelUpdateService(str(db_path))
|
||||
|
||||
entry = {
|
||||
"id": 42,
|
||||
"name": "v1 paid",
|
||||
"availability": "Public",
|
||||
"paidAccess": {"permanent": False, "endsAt": None},
|
||||
"files": [],
|
||||
"images": [],
|
||||
}
|
||||
|
||||
version = service._extract_single_version(entry, index=0)
|
||||
|
||||
assert version is not None
|
||||
assert version.is_early_access is True
|
||||
assert version.is_paid is False
|
||||
assert version.early_access_ends_at is None
|
||||
assert version.paid_access == '{"permanent": false, "endsAt": null}'
|
||||
assert ModelUpdateRecord._is_early_access_active(version) is True
|
||||
|
||||
|
||||
def test_normalize_paid_access_accepts_json_string():
|
||||
"""The by-hash enrichment path may hand paidAccess to _normalize_paid_access
|
||||
as a JSON string; both the permanent and timed shapes must normalize."""
|
||||
@@ -841,14 +867,21 @@ def test_normalize_paid_access_accepts_json_string():
|
||||
)
|
||||
assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"}
|
||||
|
||||
empty = ModelUpdateService._normalize_paid_access(
|
||||
# A timed gate whose window end is not recorded yet. CivitAI only returns a
|
||||
# non-null DTO for an ACTIVE gate (tombstones come back as null) and enforces
|
||||
# this shape too - the model page reports canDownload: false for it - so it
|
||||
# must be kept. Dropping it was the #1060 class of bug.
|
||||
pending_end = ModelUpdateService._normalize_paid_access(
|
||||
'{"permanent": false, "endsAt": null}'
|
||||
)
|
||||
assert empty is None
|
||||
assert pending_end == {"permanent": False, "endsAt": None}
|
||||
|
||||
malformed = ModelUpdateService._normalize_paid_access("{not json")
|
||||
assert malformed is None
|
||||
|
||||
# No gate keys at all is not a gate signal.
|
||||
assert ModelUpdateService._normalize_paid_access("{}") is None
|
||||
|
||||
|
||||
def test_has_update_for_base_hide_paid():
|
||||
"""hide_paid also suppresses permanent paid versions in the same-base
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for the shared CivitAI paidAccess interpretation.
|
||||
|
||||
These rules decide both the Paid/Early Access badges and whether a download is
|
||||
warned about, so the interesting cases are the boundary shapes CivitAI actually
|
||||
emits.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.paid_access import (
|
||||
is_early_access_deadline_active,
|
||||
is_gate_active,
|
||||
is_permanent_paid,
|
||||
normalize_paid_access,
|
||||
parse_civitai_timestamp,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
("{not json", None),
|
||||
("[1, 2]", None),
|
||||
({}, None),
|
||||
({"unknown": 1}, None),
|
||||
# A non-null DTO from CivitAI's public API is always an active gate, so a
|
||||
# timed gate with no recorded end must survive normalization.
|
||||
({"permanent": False, "endsAt": None}, {"permanent": False, "endsAt": None}),
|
||||
({"permanent": True, "endsAt": None}, {"permanent": True, "endsAt": None}),
|
||||
(
|
||||
{"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"},
|
||||
{"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_paid_access_shapes(value, expected):
|
||||
assert normalize_paid_access(value) == expected
|
||||
|
||||
|
||||
def test_normalize_paid_access_accepts_json_text():
|
||||
"""The by-hash enrichment path can carry the DTO as a JSON string."""
|
||||
|
||||
assert normalize_paid_access('{"permanent": true, "endsAt": null}') == {
|
||||
"permanent": True,
|
||||
"endsAt": None,
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_paid_access_blank_ends_at_is_none():
|
||||
assert normalize_paid_access({"permanent": False, "endsAt": " "}) == {
|
||||
"permanent": False,
|
||||
"endsAt": None,
|
||||
}
|
||||
|
||||
|
||||
def test_is_permanent_paid():
|
||||
assert is_permanent_paid({"permanent": True, "endsAt": None}) is True
|
||||
assert is_permanent_paid({"permanent": False, "endsAt": None}) is False
|
||||
assert is_permanent_paid(None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"paid_access,expected",
|
||||
[
|
||||
(None, False),
|
||||
({"permanent": False, "endsAt": None}, True),
|
||||
({"permanent": True, "endsAt": None}, True),
|
||||
# A permanent gate with a stale endsAt stays active.
|
||||
({"permanent": True, "endsAt": "2020-01-01T00:00:00.000Z"}, True),
|
||||
({"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"}, True),
|
||||
({"permanent": False, "endsAt": "2026-08-27T16:56:57.438Z"}, False),
|
||||
({"permanent": False, "endsAt": "not-a-date"}, True),
|
||||
],
|
||||
)
|
||||
def test_is_gate_active(paid_access, expected):
|
||||
assert is_gate_active(paid_access, now=NOW) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ends_at,expected",
|
||||
[
|
||||
(None, False),
|
||||
("", False),
|
||||
("2026-10-10T13:10:17.404Z", True),
|
||||
("2026-08-27T16:56:57.438Z", False),
|
||||
("garbage", True),
|
||||
],
|
||||
)
|
||||
def test_is_early_access_deadline_active(ends_at, expected):
|
||||
assert is_early_access_deadline_active(ends_at, now=NOW) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(None, None),
|
||||
(123, None),
|
||||
("", None),
|
||||
("garbage", None),
|
||||
("2026-10-10T13:10:17.404Z", datetime(2026, 10, 10, 13, 10, 17, 404000, tzinfo=timezone.utc)),
|
||||
# Naive timestamps are assumed UTC.
|
||||
("2026-10-10T13:10:17", datetime(2026, 10, 10, 13, 10, 17, tzinfo=timezone.utc)),
|
||||
],
|
||||
)
|
||||
def test_parse_civitai_timestamp(value, expected):
|
||||
assert parse_civitai_timestamp(value) == expected
|
||||
Reference in New Issue
Block a user