gabriel / musehub public

factories.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Test factories for MuseHub ORM models.
2
3 Provides two layers:
4 1. ``*Factory`` classes (factory_boy ``Factory`` subclasses) that generate
5 realistic attribute dictionaries without touching the database.
6 2. Async ``create_*`` helpers that instantiate the ORM model from the
7 factory data, persist it, and return the refreshed ORM object.
8
9 Usage in tests::
10
11 from tests.factories import create_repo, create_profile, RepoFactory
12
13 async def test_something(db_session):
14 repo = await create_repo(db_session, owner="alice", visibility="public")
15 assert repo.owner == "alice"
16
17 # Data-only (no DB) — useful for unit-testing pure functions:
18 data = RepoFactory(name="My Jazz EP", owner="charlie")
19 assert data["slug"] == "my-jazz-ep"
20 """
21 from __future__ import annotations
22
23 import itertools
24 import re
25 import secrets
26
27 from muse.core.types import blob_id, content_hash
28 from datetime import datetime, timezone
29
30 import factory
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from musehub.core.genesis import compute_identity_id, compute_issue_id, compute_proposal_id
34 from musehub.db.musehub_identity_models import MusehubIdentity
35 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
36 from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal
37 from musehub.types.json_types import JSONValue
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44 _id_seq = itertools.count()
45
46
47 def _uid() -> str:
48 return secrets.token_hex(16)
49
50
51 def _now() -> datetime:
52 return datetime.now(tz=timezone.utc)
53
54
55 def _slugify(name: str) -> str:
56 """Convert a human-readable name to a URL-safe slug."""
57 return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "repo"
58
59
60 # ---------------------------------------------------------------------------
61 # Attribute factories (no DB access)
62 # ---------------------------------------------------------------------------
63
64 class RepoFactory(factory.Factory):
65 """Generate attribute dicts for MusehubRepo."""
66
67 class Meta:
68 model = dict
69
70 name: str = factory.Sequence(lambda n: f"Test Repo {n}")
71 owner: str = "testuser"
72 slug: str = factory.LazyAttribute(lambda o: _slugify(o.name))
73 visibility: str = "public"
74 owner_user_id: str = factory.LazyAttribute(lambda o: compute_identity_id(o.owner.encode()))
75 description: str = factory.LazyAttribute(lambda o: f"Description for {o.name}")
76 tags = factory.LazyFunction(list)
77
78
79 class BranchFactory(factory.Factory):
80 class Meta:
81 model = dict
82
83 name: str = "main"
84 head_commit_id: str | None = None
85
86
87 class CommitFactory(factory.Factory):
88 class Meta:
89 model = dict
90
91 commit_id: str = factory.LazyFunction(lambda: content_hash({"seq": next(_id_seq)}))
92 message: str = factory.Sequence(lambda n: f"feat: commit number {n}")
93 author: str = "testuser"
94 branch: str = "main"
95 parent_ids = factory.LazyFunction(list)
96 snapshot_id: str | None = None
97 timestamp: datetime = factory.LazyFunction(_now)
98
99
100 class ProfileFactory(factory.Factory):
101 class Meta:
102 model = dict
103
104 user_id: str = factory.LazyFunction(_uid)
105 username: str = factory.Sequence(lambda n: f"user{n}")
106 display_name: str = factory.LazyAttribute(lambda o: o.username.title())
107 bio: str = "A musician who uses Muse VCS."
108 avatar_url: str | None = None
109 location: str | None = None
110 website_url: str | None = None
111 social_url: str | None = None
112 is_verified: bool = False
113 cc_license: str | None = None
114 pinned_repo_ids = factory.LazyFunction(list)
115
116
117 class IssueFactory(factory.Factory):
118 class Meta:
119 model = dict
120
121 title: str = factory.Sequence(lambda n: f"Issue #{n}")
122 body: str = "Issue body text."
123 author: str = "testuser"
124 status: str = "open"
125
126
127 class SessionFactory(factory.Factory):
128 class Meta:
129 model = dict
130
131 session_id: str = factory.LazyFunction(_uid)
132 participants = factory.LazyFunction(lambda: ["testuser"])
133 commits = factory.LazyFunction(list)
134 notes: str | None = None
135 location: str | None = None
136 intent: str | None = None
137
138
139 # ---------------------------------------------------------------------------
140 # Async persistence helpers
141 # ---------------------------------------------------------------------------
142
143 async def create_repo(
144 session: AsyncSession,
145 **kwargs: JSONValue,
146 ) -> MusehubRepo:
147 """Insert and return a MusehubRepo row using RepoFactory defaults."""
148 from musehub.core.genesis import compute_repo_id
149 data = RepoFactory(**kwargs)
150 created_at = _now()
151 owner_user_id = str(data["owner_user_id"])
152 slug = str(data["slug"])
153 domain = str(data.get("domain_id") or "")
154 repo_id = compute_repo_id(owner_user_id, slug, domain, created_at.isoformat())
155 repo = MusehubRepo(
156 repo_id=repo_id,
157 name=data["name"],
158 owner=data["owner"],
159 slug=slug,
160 visibility=data["visibility"],
161 owner_user_id=owner_user_id,
162 description=data["description"],
163 tags=data["tags"],
164 created_at=created_at,
165 domain_id=data.get("domain_id"),
166 )
167 session.add(repo)
168 await session.commit()
169 await session.refresh(repo)
170 return repo
171
172
173 async def create_branch(
174 session: AsyncSession,
175 repo_id: str,
176 **kwargs: JSONValue,
177 ) -> MusehubBranch:
178 """Insert and return a MusehubBranch row."""
179 from musehub.core.genesis import compute_branch_id
180 data = BranchFactory(**kwargs)
181 name = str(data["name"])
182 branch = MusehubBranch(
183 branch_id=compute_branch_id(repo_id, name),
184 repo_id=repo_id,
185 name=name,
186 head_commit_id=data.get("head_commit_id"),
187 )
188 session.add(branch)
189 await session.commit()
190 await session.refresh(branch)
191 return branch
192
193
194 async def create_commit(
195 session: AsyncSession,
196 repo_id: str,
197 **kwargs: JSONValue,
198 ) -> MusehubCommit:
199 """Insert and return a MusehubCommit row."""
200 data = CommitFactory(**kwargs)
201 commit = MusehubCommit(
202 commit_id=data["commit_id"],
203 message=data["message"],
204 author=data["author"],
205 branch=data["branch"],
206 parent_ids=data["parent_ids"],
207 snapshot_id=data.get("snapshot_id"),
208 timestamp=data.get("timestamp") or _now(),
209 )
210 session.add(commit)
211 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=data["commit_id"]))
212 await session.commit()
213 await session.refresh(commit)
214 return commit
215
216
217 async def create_profile(
218 session: AsyncSession,
219 **kwargs: JSONValue,
220 ) -> MusehubIdentity:
221 """Insert and return a MusehubIdentity row."""
222 data = ProfileFactory(**kwargs)
223 profile = MusehubIdentity(
224 identity_id=data["user_id"],
225 handle=data["username"],
226 identity_type="human",
227 display_name=data["display_name"],
228 bio=data["bio"],
229 avatar_url=data.get("avatar_url"),
230 location=data.get("location"),
231 website_url=data.get("website_url"),
232 social_url=data.get("social_url"),
233 is_verified=data["is_verified"],
234 cc_license=data.get("cc_license"),
235 )
236 session.add(profile)
237 await session.commit()
238 await session.refresh(profile)
239 return profile
240
241
242 async def create_repo_with_branch(
243 session: AsyncSession,
244 **kwargs: JSONValue,
245 ) -> tuple[MusehubRepo, MusehubBranch]:
246 """Convenience: create a repo + default 'main' branch atomically."""
247 repo = await create_repo(session, **kwargs)
248 branch = await create_branch(session, repo_id=str(repo.repo_id), name="main")
249 return repo, branch
250
251
252 async def create_issue(
253 session: AsyncSession,
254 repo_id: str,
255 *,
256 author: str = "testuser",
257 title: str = "Test issue",
258 body: str = "",
259 state: str = "open",
260 number: int | None = None,
261 ) -> MusehubIssue:
262 """Insert and return a MusehubIssue row."""
263 from sqlalchemy import func, select as sa_select
264 if number is None:
265 result = await session.execute(
266 sa_select(func.count()).select_from(MusehubIssue).where(MusehubIssue.repo_id == repo_id)
267 )
268 number = (result.scalar() or 0) + 1
269 now = _now()
270 author_identity_id = compute_identity_id(author.encode())
271 issue = MusehubIssue(
272 issue_id=compute_issue_id(repo_id, author_identity_id, now.isoformat()),
273 repo_id=repo_id,
274 number=number,
275 title=title,
276 body=body,
277 state=state,
278 author=author,
279 created_at=now,
280 updated_at=now,
281 )
282 session.add(issue)
283 await session.commit()
284 await session.refresh(issue)
285 return issue
286
287
288 async def create_proposal(
289 session: AsyncSession,
290 repo_id: str,
291 *,
292 author: str = "testuser",
293 title: str = "Test proposal",
294 body: str = "",
295 state: str = "open",
296 from_branch: str = "feature/test",
297 to_branch: str = "main",
298 proposal_number: int | None = None,
299 ) -> MusehubProposal:
300 """Insert and return a MusehubProposal row."""
301 from sqlalchemy import func, select as sa_select
302 if proposal_number is None:
303 result = await session.execute(
304 sa_select(func.count()).select_from(MusehubProposal).where(MusehubProposal.repo_id == repo_id)
305 )
306 proposal_number = (result.scalar() or 0) + 1
307 now = _now()
308 author_identity_id = compute_identity_id(author.encode())
309 proposal = MusehubProposal(
310 proposal_id=compute_proposal_id(repo_id, author_identity_id, from_branch, to_branch, now.isoformat()),
311 repo_id=repo_id,
312 proposal_number=proposal_number,
313 title=title,
314 body=body,
315 state=state,
316 author=author,
317 from_branch=from_branch,
318 to_branch=to_branch,
319 created_at=now,
320 updated_at=now,
321 )
322 session.add(proposal)
323 await session.commit()
324 await session.refresh(proposal)
325 return proposal