main (#16) flattened get_client records and renamed create_client's returned identifier to 'id'. Read create_client(...)['id'] in the real-core composition test, and make _default_repo_name accept the flat record (top-level name / id) as well as the historical nested shape. 46 tests pass on top of origin/main.
529 lines
20 KiB
Python
529 lines
20 KiB
Python
"""Tests for ``gitea_saas.intake_factory`` (factory intake, issue #4).
|
|
|
|
Hermetic by design: an in-memory ``MockGitea`` + a reference billing core
|
|
over a temp SQLite DB. No network, no Stripe, no Gitea, no dispatcher
|
|
files.
|
|
|
|
Covers the issue #4 acceptance checklist:
|
|
|
|
1. provision creates repo once; second call reuses (idempotent)
|
|
2. funded submit creates Gitea issue immediately, delivered=true
|
|
3. unfunded submit queues (no Gitea issue created), delivered=false
|
|
4. unlock flushes queued -> Gitea with p1 label, marks shipped, second
|
|
unlock is a no-op
|
|
5. lock stops shipping: subsequent submits queue again
|
|
6. client with balance below floor is ``client_unfunded() == True``
|
|
7. all tests against MockGitea + tmp DB; no network
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import types
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gitea_saas.intake_factory import (
|
|
DEFAULT_DEBIT_FLOOR_MINOR,
|
|
IntakeFactory,
|
|
UnknownClientError,
|
|
register_factory_routes,
|
|
)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reference billing core (issue #1 contract subset: (ok, details) tuples)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ReferenceCore:
|
|
"""Minimal billing core honouring the documented ``(ok, details)``
|
|
contract over a temp SQLite DB (subset used by the factory)."""
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS clients (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
tier TEXT NOT NULL DEFAULT 'standard',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
meta_json TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ledger (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
client_id INTEGER NOT NULL REFERENCES clients(id),
|
|
kind TEXT NOT NULL CHECK (kind IN ('credit','debit','hold','release','refund')),
|
|
amount_minor INTEGER NOT NULL,
|
|
currency TEXT NOT NULL DEFAULT 'USD',
|
|
idempotency_key TEXT UNIQUE NOT NULL,
|
|
ref_type TEXT,
|
|
ref_id TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self._conn = sqlite3.connect(str(db_path))
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.executescript(self.SCHEMA)
|
|
self._seq = 0
|
|
|
|
def _key(self) -> str:
|
|
self._seq += 1
|
|
return f"ref-{self._seq}"
|
|
|
|
def create_client(self, name: str, meta: dict | None = None):
|
|
try:
|
|
cur = self._conn.execute(
|
|
"INSERT INTO clients (name, meta_json, created_at) VALUES (?, ?, ?)",
|
|
(name, json.dumps(meta or {}), _now_iso()))
|
|
except sqlite3.IntegrityError:
|
|
return False, {"error": {"code": "DUPLICATE_CLIENT", "message": name}}
|
|
self._conn.commit()
|
|
return True, {"client_id": cur.lastrowid, "name": name}
|
|
|
|
def credit(self, client_id: int, amount_minor: int):
|
|
self._conn.execute(
|
|
"INSERT INTO ledger (client_id, kind, amount_minor, currency,"
|
|
" idempotency_key, created_at) VALUES (?, 'credit', ?, 'USD', ?, ?)",
|
|
(client_id, amount_minor, self._key(), _now_iso()))
|
|
self._conn.commit()
|
|
return True, {"client_id": client_id, "balance": self._balance(client_id)}
|
|
|
|
def _balance(self, client_id: int) -> int:
|
|
row = self._conn.execute(
|
|
"SELECT COALESCE(SUM(CASE WHEN kind='credit' THEN amount_minor"
|
|
" ELSE -amount_minor END), 0) FROM ledger WHERE client_id = ?",
|
|
(client_id,)).fetchone()
|
|
return int(row[0])
|
|
|
|
def get_client(self, client_id: int, include_balance: bool = True):
|
|
row = self._conn.execute(
|
|
"SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()
|
|
if row is None:
|
|
return False, {"error": {"code": "UNKNOWN_CLIENT",
|
|
"message": f"no client with id {client_id}"}}
|
|
client = dict(row)
|
|
client["meta"] = json.loads(client.pop("meta_json") or "{}")
|
|
details = {"client": client}
|
|
if include_balance:
|
|
details["balance"] = self._balance(client_id)
|
|
return True, details
|
|
|
|
def get_balance(self, client_id: int):
|
|
row = self._conn.execute(
|
|
"SELECT id FROM clients WHERE id = ?", (client_id,)).fetchone()
|
|
if row is None:
|
|
return False, {"error": {"code": "UNKNOWN_CLIENT", "message": "no client"}}
|
|
return True, {"client_id": client_id, "balance": self._balance(client_id)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MockGitea (in-memory fake of gitea_saas.gitea_client.GiteaClient)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MockGitea:
|
|
"""In-memory Gitea: repos, per-repo labels (names -> ids) and issues."""
|
|
|
|
def __init__(self):
|
|
self.repos: dict = {}
|
|
self.labels: dict = {}
|
|
self.created_repo_calls: list = []
|
|
self._label_seq = 0
|
|
|
|
# -- repo provisioning -------------------------------------------------
|
|
|
|
def repo_exists(self, name: str) -> bool:
|
|
return name in self.repos
|
|
|
|
def create_org_repo(self, name: str) -> dict:
|
|
self.created_repo_calls.append(name)
|
|
if name not in self.repos:
|
|
self.repos[name] = {"name": name, "issues": []}
|
|
self.labels[name] = {"p1": self._next_label_id()}
|
|
return {"name": name}
|
|
|
|
# -- labels ------------------------------------------------------------
|
|
|
|
def _next_label_id(self) -> int:
|
|
self._label_seq += 1
|
|
return self._label_seq
|
|
|
|
def add_label(self, repo: str, name: str) -> int:
|
|
if name not in self.labels.setdefault(repo, {}):
|
|
self.labels[repo][name] = self._next_label_id()
|
|
return self.labels[repo][name]
|
|
|
|
def list_labels(self, repo: str) -> list:
|
|
return [{"id": i, "name": n} for n, i in self.labels.get(repo, {}).items()]
|
|
|
|
def label_id(self, repo: str, name: str) -> int | None:
|
|
return self.labels.get(repo, {}).get(name)
|
|
|
|
# -- issues ------------------------------------------------------------
|
|
|
|
def create_issue(self, repo: str, title: str, body: str,
|
|
labels: list | None = None) -> int:
|
|
rec = self.repos[repo]
|
|
number = len(rec["issues"]) + 1
|
|
label_ids = []
|
|
for name in labels or []:
|
|
lid = self.label_id(repo, name)
|
|
if lid is not None and lid not in label_ids:
|
|
label_ids.append(lid)
|
|
rec["issues"].append({
|
|
"number": number, "title": title, "body": body, "labels": label_ids,
|
|
})
|
|
return number
|
|
|
|
def issue(self, repo: str, number: int) -> dict:
|
|
return self.repos[repo]["issues"][number - 1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture()
|
|
def env(tmp_path: Path, monkeypatch):
|
|
core = ReferenceCore(tmp_path / "billing.db")
|
|
gitea = MockGitea()
|
|
cfg = types.SimpleNamespace(db_path=tmp_path / "billing.db",
|
|
api_key="test-api-key",
|
|
debit_floor_minor=DEFAULT_DEBIT_FLOOR_MINOR)
|
|
factory = IntakeFactory(core, gitea, cfg)
|
|
return types.SimpleNamespace(core=core, gitea=gitea, cfg=cfg, factory=factory,
|
|
db_path=tmp_path / "billing.db")
|
|
|
|
|
|
def _client(env, name: str = "Acme Corp", amount_minor: int = 0) -> int:
|
|
ok, details = env.core.create_client(name)
|
|
assert ok, details
|
|
client_id = details["client_id"]
|
|
if amount_minor:
|
|
ok, _ = env.core.credit(client_id, amount_minor)
|
|
assert ok
|
|
return client_id
|
|
|
|
|
|
def _queued_rows(db_path: Path, client_id: int) -> list:
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM pending_issues WHERE client_id = ? ORDER BY id",
|
|
(client_id,)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _mapping_rows(db_path: Path, client_id: int) -> list:
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM client_repos WHERE client_id = ?", (client_id,)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. provision: creates once, reuses on second call (idempotent)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_provision_creates_repo_once_and_reuses(env):
|
|
cid = _client(env, "Acme Corp")
|
|
repo = env.factory.provision_client(cid)
|
|
assert repo == "acme-corp"
|
|
assert env.gitea.repo_exists("acme-corp")
|
|
assert env.gitea.created_repo_calls == ["acme-corp"]
|
|
|
|
# Second provision: same repo, no duplicate creation, one mapping row.
|
|
again = env.factory.provision_client(cid)
|
|
assert again == "acme-corp"
|
|
assert env.gitea.created_repo_calls == ["acme-corp"]
|
|
assert len(_mapping_rows(env.db_path, cid)) == 1
|
|
|
|
|
|
def test_provision_explicit_repo_name_and_cross_client_isolation(env):
|
|
cid = _client(env, "Globex")
|
|
repo = env.factory.provision_client(cid, "globex-main")
|
|
assert repo == "globex-main"
|
|
assert env.gitea.repo_exists("globex-main")
|
|
# A different client gets its own mapping and repo.
|
|
cid2 = _client(env, "Initech")
|
|
repo2 = env.factory.provision_client(cid2)
|
|
assert repo2 == "initech"
|
|
assert len(env.gitea.created_repo_calls) == 2
|
|
assert len(_mapping_rows(env.db_path, cid)) == 1
|
|
assert len(_mapping_rows(env.db_path, cid2)) == 1
|
|
|
|
|
|
def test_provision_rejects_bad_repo_name_and_unknown_client(env):
|
|
cid = _client(env)
|
|
with pytest.raises(Exception):
|
|
env.factory.provision_client(cid, "bad name with spaces")
|
|
with pytest.raises(UnknownClientError):
|
|
env.factory.provision_client(9999, "whatever-repo")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. funded submit: Gitea issue immediately, delivered=true
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_funded_submit_delivers_immediately(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=10_000)
|
|
env.factory.provision_client(cid)
|
|
env.gitea.add_label("acme-corp", "p2")
|
|
result = env.factory.submit_issue(
|
|
cid, "acme-corp", "Fix the bug",
|
|
"## Acceptance\n- [ ] it works", labels=["p2"])
|
|
assert result == {"delivered": True, "issue_number": 1}
|
|
issue = env.gitea.issue("acme-corp", 1)
|
|
assert issue["title"] == "Fix the bug"
|
|
assert issue["body"] == "## Acceptance\n- [ ] it works"
|
|
assert issue["labels"] == [env.gitea.label_id("acme-corp", "p2")]
|
|
# Nothing queued for a funded direct delivery.
|
|
assert _queued_rows(env.db_path, cid) == []
|
|
|
|
|
|
def test_funded_submit_without_labels(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=10_000)
|
|
env.factory.provision_client(cid)
|
|
result = env.factory.submit_issue(cid, "acme-corp", "No labels", "")
|
|
assert result["delivered"] is True
|
|
assert env.gitea.issue("acme-corp", 1)["labels"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. unfunded submit: queued, no Gitea issue, delivered=false
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_unfunded_submit_queues(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=0)
|
|
env.factory.provision_client(cid)
|
|
result = env.factory.submit_issue(
|
|
cid, "acme-corp", "Queued work", "## Acceptance\n- [ ] later")
|
|
assert result["delivered"] is False
|
|
assert result["reason"] == "unfunded"
|
|
assert isinstance(result["pending_id"], int)
|
|
assert env.gitea.repos["acme-corp"]["issues"] == []
|
|
rows = _queued_rows(env.db_path, cid)
|
|
assert len(rows) == 1
|
|
row = rows[0]
|
|
assert row["status"] == "queued"
|
|
assert row["title"] == "Queued work"
|
|
assert json.loads(row["body_json"]) == "## Acceptance\n- [ ] later"
|
|
assert json.loads(row["labels_json"]) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. unlock: flush queued -> Gitea with p1 label, shipped, second no-op
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_unlock_flushes_queued_with_p1_and_is_idempotent(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=0)
|
|
env.factory.provision_client(cid)
|
|
env.gitea.add_label("acme-corp", "p3")
|
|
r1 = env.factory.submit_issue(cid, "acme-corp", "Task A", "body A", labels=["p3"])
|
|
r2 = env.factory.submit_issue(cid, "acme-corp", "Task B", "body B")
|
|
assert r1["delivered"] is False and r2["delivered"] is False
|
|
|
|
flushed = env.factory.unlock(cid)
|
|
assert flushed["flushed"] == 2
|
|
assert flushed["issues"] == [1, 2]
|
|
p1 = env.gitea.label_id("acme-corp", "p1")
|
|
p3 = env.gitea.label_id("acme-corp", "p3")
|
|
a = env.gitea.issue("acme-corp", 1)
|
|
b = env.gitea.issue("acme-corp", 2)
|
|
assert a["title"] == "Task A" and a["body"] == "body A"
|
|
assert p1 in a["labels"] and p3 in a["labels"]
|
|
assert b["labels"] == [p1]
|
|
# Rows marked shipped.
|
|
rows = _queued_rows(env.db_path, cid)
|
|
assert len(rows) == 2 and all(r["status"] == "shipped" for r in rows)
|
|
|
|
# Second unlock: nothing queued -> no-op.
|
|
again = env.factory.unlock(cid)
|
|
assert again == {"flushed": 0, "issues": [], "lock_cleared": False}
|
|
assert len(env.gitea.repos["acme-corp"]["issues"]) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. lock: future submits queue even when funded; unlock clears + flushes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_lock_queues_funded_submits_and_unlock_resumes(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=10_000)
|
|
env.factory.provision_client(cid)
|
|
assert env.factory.lock(cid) == {"client_id": cid, "locked": True}
|
|
|
|
# Funded but locked -> queued, no Gitea issue.
|
|
result = env.factory.submit_issue(cid, "acme-corp", "Blocked by lock", "")
|
|
assert result == {"delivered": False, "pending_id": 1, "reason": "locked"}
|
|
assert env.gitea.repos["acme-corp"]["issues"] == []
|
|
|
|
# unlock flushes the queued issue AND clears the lock.
|
|
flushed = env.factory.unlock(cid)
|
|
assert flushed["flushed"] == 1 and flushed["lock_cleared"] is True
|
|
assert env.gitea.repos["acme-corp"]["issues"][0]["title"] == "Blocked by lock"
|
|
|
|
# Lock cleared -> shipping resumes.
|
|
again = env.factory.submit_issue(cid, "acme-corp", "Now shipping", "")
|
|
assert again == {"delivered": True, "issue_number": 2}
|
|
|
|
# lock() twice is idempotent; lock() on unknown client raises.
|
|
assert env.factory.lock(cid)["locked"] is True
|
|
with pytest.raises(UnknownClientError):
|
|
env.factory.lock(9999)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. client_unfunded: below the debit floor (and fail-closed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_client_unfunded_boundary(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=0)
|
|
assert env.factory.client_unfunded(cid) is True
|
|
env.core.credit(cid, DEFAULT_DEBIT_FLOOR_MINOR - 1) # 499
|
|
assert env.factory.client_unfunded(cid) is True
|
|
env.core.credit(cid, 1) # 500 == floor
|
|
assert env.factory.client_unfunded(cid) is False
|
|
env.core.credit(cid, 1000) # 1500
|
|
assert env.factory.client_unfunded(cid) is False
|
|
|
|
|
|
def test_client_unfunded_fail_closed_on_unknown_client(env):
|
|
# Unknown client -> cannot confirm funding -> unfunded (fail-closed).
|
|
assert env.factory.client_unfunded(9999) is True
|
|
with pytest.raises(UnknownClientError):
|
|
env.factory.submit_issue(9999, "acme-corp", "Nope", "")
|
|
|
|
|
|
def test_floor_override_via_config(env):
|
|
cid = _client(env, "Acme Corp", amount_minor=200)
|
|
env.cfg.debit_floor_minor = 100
|
|
assert env.factory.client_unfunded(cid) is False # 200 >= 100
|
|
env.cfg.debit_floor_minor = 300
|
|
assert env.factory.client_unfunded(cid) is True # 200 < 300
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP surface (register_factory_routes), same X-API-Key discipline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture()
|
|
def app_client(env):
|
|
from flask import Flask
|
|
|
|
app = Flask(__name__)
|
|
app.config["API_KEY"] = env.cfg.api_key
|
|
register_factory_routes(app, env.core, env.gitea, env.cfg)
|
|
return app.test_client()
|
|
|
|
|
|
def _post(app, path, payload=None, key="test-api-key"):
|
|
headers = {"X-API-Key": key} if key else {}
|
|
return app.post(path, json=payload or {}, headers=headers)
|
|
|
|
|
|
def test_routes_require_api_key(app_client):
|
|
resp = app_client.post("/api/factory/provision", json={"client_id": 1})
|
|
assert resp.status_code == 401
|
|
resp = app_client.post("/api/factory/1/unlock")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_route_provision_submit_unlock_lock_flow(env, app_client):
|
|
cid = _client(env, "Acme Corp", amount_minor=0)
|
|
resp = _post(app_client, "/api/factory/provision", {"client_id": cid})
|
|
assert resp.status_code == 200
|
|
assert resp.get_json() == {"repo": "acme-corp"}
|
|
|
|
# Unfunded submit -> 202 queued.
|
|
resp = _post(app_client, f"/api/factory/{cid}/issues",
|
|
{"repo": "acme-corp", "title": "Queued via API",
|
|
"acceptance_body": "## Acceptance"})
|
|
assert resp.status_code == 202
|
|
body = resp.get_json()
|
|
assert body["delivered"] is False and body["reason"] == "unfunded"
|
|
|
|
# Unlock -> 200 flushed with p1.
|
|
resp = _post(app_client, f"/api/factory/{cid}/unlock")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["flushed"] == 1
|
|
|
|
# Lock -> 200; funded submit afterwards still queues.
|
|
env.core.credit(cid, 10_000)
|
|
resp = _post(app_client, f"/api/factory/{cid}/lock")
|
|
assert resp.get_json() == {"client_id": cid, "locked": True}
|
|
resp = _post(app_client, f"/api/factory/{cid}/issues",
|
|
{"repo": "acme-corp", "title": "Locked via API"})
|
|
assert resp.get_json()["reason"] == "locked"
|
|
|
|
|
|
def test_route_unknown_client_404(env, app_client):
|
|
resp = _post(app_client, "/api/factory/provision", {"client_id": 9999})
|
|
assert resp.status_code == 404
|
|
assert resp.get_json()["code"] == "unknown_client"
|
|
|
|
|
|
def test_route_validation_400(app_client):
|
|
resp = _post(app_client, "/api/factory/provision", {"client_id": -1})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Hermeticity guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hermetic_no_real_gitea_or_network_used(env):
|
|
"""The whole suite runs against mocks; a real GiteaClient would need a
|
|
token and a network. This test pins the factory's seams: every gitea
|
|
interaction goes through the injected mock object."""
|
|
assert isinstance(env.gitea, MockGitea)
|
|
assert isinstance(env.factory._gitea, MockGitea)
|
|
|
|
|
|
def test_real_billing_core_composition(tmp_path: Path):
|
|
"""The factory composes with the REAL billing core (issue #1, merged).
|
|
|
|
Uses the actual ``gitea_saas.billing_core.BillingCore`` over a temp DB
|
|
-- still no network: Gitea stays mocked.
|
|
"""
|
|
from gitea_saas.billing_core import BillingCore
|
|
|
|
core = BillingCore(tmp_path / "real.db")
|
|
gitea = MockGitea()
|
|
cfg = types.SimpleNamespace(db_path=tmp_path / "real.db", api_key="k",
|
|
debit_floor_minor=DEFAULT_DEBIT_FLOOR_MINOR)
|
|
factory = IntakeFactory(core, gitea, cfg)
|
|
|
|
ok, details = core.create_client("Acme Corp")
|
|
assert ok
|
|
cid = details["id"]
|
|
assert core.credit(cid, 10_000, "idem-1")[0] is True
|
|
assert factory.provision_client(cid) == "acme-corp"
|
|
result = factory.submit_issue(cid, "acme-corp", "Funded with real core", "body")
|
|
assert result == {"delivered": True, "issue_number": 1}
|
|
|
|
ok, details2 = core.create_client("Poor Corp")
|
|
assert ok
|
|
cid2 = details2["id"]
|
|
assert factory.provision_client(cid2) == "poor-corp"
|
|
queued = factory.submit_issue(cid2, "poor-corp", "Queued", "body")
|
|
assert queued["delivered"] is False and queued["reason"] == "unfunded"
|
|
|
|
flushed = factory.unlock(cid2)
|
|
assert flushed["flushed"] == 1
|
|
p1 = gitea.label_id("poor-corp", "p1")
|
|
assert p1 in gitea.issue("poor-corp", 1)["labels"]
|