"""Test factories for MuseHub ORM models. Provides two layers: 1. ``*Factory`` classes (factory_boy ``Factory`` subclasses) that generate realistic attribute dictionaries without touching the database. 2. Async ``create_*`` helpers that instantiate the ORM model from the factory data, persist it, and return the refreshed ORM object. Usage in tests:: from tests.factories import create_repo, create_profile, RepoFactory async def test_something(db_session): repo = await create_repo(db_session, owner="alice", visibility="public") assert repo.owner == "alice" # Data-only (no DB) — useful for unit-testing pure functions: data = RepoFactory(name="My Jazz EP", owner="charlie") assert data["slug"] == "my-jazz-ep" """ from __future__ import annotations import itertools import re import secrets from muse.core.types import blob_id, content_hash from datetime import datetime, timezone import factory from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_issue_id, compute_proposal_id from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal from musehub.types.json_types import JSONValue # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _id_seq = itertools.count() def _uid() -> str: return secrets.token_hex(16) def _now() -> datetime: return datetime.now(tz=timezone.utc) def _slugify(name: str) -> str: """Convert a human-readable name to a URL-safe slug.""" return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "repo" # --------------------------------------------------------------------------- # Attribute factories (no DB access) # --------------------------------------------------------------------------- class RepoFactory(factory.Factory): """Generate attribute dicts for MusehubRepo.""" class Meta: model = dict name: str = factory.Sequence(lambda n: f"Test Repo {n}") owner: str = "testuser" slug: str = factory.LazyAttribute(lambda o: _slugify(o.name)) visibility: str = "public" owner_user_id: str = factory.LazyAttribute(lambda o: compute_identity_id(o.owner.encode())) description: str = factory.LazyAttribute(lambda o: f"Description for {o.name}") tags = factory.LazyFunction(list) class BranchFactory(factory.Factory): class Meta: model = dict name: str = "main" head_commit_id: str | None = None class CommitFactory(factory.Factory): class Meta: model = dict commit_id: str = factory.LazyFunction(lambda: content_hash({"seq": next(_id_seq)})) message: str = factory.Sequence(lambda n: f"feat: commit number {n}") author: str = "testuser" branch: str = "main" parent_ids = factory.LazyFunction(list) snapshot_id: str | None = None timestamp: datetime = factory.LazyFunction(_now) class ProfileFactory(factory.Factory): class Meta: model = dict user_id: str = factory.LazyFunction(_uid) username: str = factory.Sequence(lambda n: f"user{n}") display_name: str = factory.LazyAttribute(lambda o: o.username.title()) bio: str = "A musician who uses Muse VCS." avatar_url: str | None = None location: str | None = None website_url: str | None = None social_url: str | None = None is_verified: bool = False cc_license: str | None = None pinned_repo_ids = factory.LazyFunction(list) class IssueFactory(factory.Factory): class Meta: model = dict title: str = factory.Sequence(lambda n: f"Issue #{n}") body: str = "Issue body text." author: str = "testuser" status: str = "open" class SessionFactory(factory.Factory): class Meta: model = dict session_id: str = factory.LazyFunction(_uid) participants = factory.LazyFunction(lambda: ["testuser"]) commits = factory.LazyFunction(list) notes: str | None = None location: str | None = None intent: str | None = None # --------------------------------------------------------------------------- # Async persistence helpers # --------------------------------------------------------------------------- async def create_repo( session: AsyncSession, **kwargs: JSONValue, ) -> MusehubRepo: """Insert and return a MusehubRepo row using RepoFactory defaults.""" from musehub.core.genesis import compute_repo_id data = RepoFactory(**kwargs) created_at = _now() owner_user_id = str(data["owner_user_id"]) slug = str(data["slug"]) domain = str(data.get("domain_id") or "") repo_id = compute_repo_id(owner_user_id, slug, domain, created_at.isoformat()) repo = MusehubRepo( repo_id=repo_id, name=data["name"], owner=data["owner"], slug=slug, visibility=data["visibility"], owner_user_id=owner_user_id, description=data["description"], tags=data["tags"], created_at=created_at, domain_id=data.get("domain_id"), ) session.add(repo) await session.commit() await session.refresh(repo) return repo async def create_branch( session: AsyncSession, repo_id: str, **kwargs: JSONValue, ) -> MusehubBranch: """Insert and return a MusehubBranch row.""" from musehub.core.genesis import compute_branch_id data = BranchFactory(**kwargs) name = str(data["name"]) branch = MusehubBranch( branch_id=compute_branch_id(repo_id, name), repo_id=repo_id, name=name, head_commit_id=data.get("head_commit_id"), ) session.add(branch) await session.commit() await session.refresh(branch) return branch async def create_commit( session: AsyncSession, repo_id: str, **kwargs: JSONValue, ) -> MusehubCommit: """Insert and return a MusehubCommit row.""" data = CommitFactory(**kwargs) commit = MusehubCommit( commit_id=data["commit_id"], message=data["message"], author=data["author"], branch=data["branch"], parent_ids=data["parent_ids"], snapshot_id=data.get("snapshot_id"), timestamp=data.get("timestamp") or _now(), ) session.add(commit) session.add(MusehubCommitRef(repo_id=repo_id, commit_id=data["commit_id"])) await session.commit() await session.refresh(commit) return commit async def create_profile( session: AsyncSession, **kwargs: JSONValue, ) -> MusehubIdentity: """Insert and return a MusehubIdentity row.""" data = ProfileFactory(**kwargs) profile = MusehubIdentity( identity_id=data["user_id"], handle=data["username"], identity_type="human", display_name=data["display_name"], bio=data["bio"], avatar_url=data.get("avatar_url"), location=data.get("location"), website_url=data.get("website_url"), social_url=data.get("social_url"), is_verified=data["is_verified"], cc_license=data.get("cc_license"), ) session.add(profile) await session.commit() await session.refresh(profile) return profile async def create_repo_with_branch( session: AsyncSession, **kwargs: JSONValue, ) -> tuple[MusehubRepo, MusehubBranch]: """Convenience: create a repo + default 'main' branch atomically.""" repo = await create_repo(session, **kwargs) branch = await create_branch(session, repo_id=str(repo.repo_id), name="main") return repo, branch async def create_issue( session: AsyncSession, repo_id: str, *, author: str = "testuser", title: str = "Test issue", body: str = "", state: str = "open", number: int | None = None, ) -> MusehubIssue: """Insert and return a MusehubIssue row.""" from sqlalchemy import func, select as sa_select if number is None: result = await session.execute( sa_select(func.count()).select_from(MusehubIssue).where(MusehubIssue.repo_id == repo_id) ) number = (result.scalar() or 0) + 1 now = _now() author_identity_id = compute_identity_id(author.encode()) issue = MusehubIssue( issue_id=compute_issue_id(repo_id, author_identity_id, now.isoformat()), repo_id=repo_id, number=number, title=title, body=body, state=state, author=author, created_at=now, updated_at=now, ) session.add(issue) await session.commit() await session.refresh(issue) return issue async def create_proposal( session: AsyncSession, repo_id: str, *, author: str = "testuser", title: str = "Test proposal", body: str = "", state: str = "open", from_branch: str = "feature/test", to_branch: str = "main", proposal_number: int | None = None, ) -> MusehubProposal: """Insert and return a MusehubProposal row.""" from sqlalchemy import func, select as sa_select if proposal_number is None: result = await session.execute( sa_select(func.count()).select_from(MusehubProposal).where(MusehubProposal.repo_id == repo_id) ) proposal_number = (result.scalar() or 0) + 1 now = _now() author_identity_id = compute_identity_id(author.encode()) proposal = MusehubProposal( proposal_id=compute_proposal_id(repo_id, author_identity_id, from_branch, to_branch, now.isoformat()), repo_id=repo_id, proposal_number=proposal_number, title=title, body=body, state=state, author=author, from_branch=from_branch, to_branch=to_branch, created_at=now, updated_at=now, ) session.add(proposal) await session.commit() await session.refresh(proposal) return proposal