gabriel / musehub public
test_musehub_profile_service.py python
291 lines 11.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Unit tests for musehub/services/musehub_profile.py.
2
3 Tests the service-layer profile functions directly (no HTTP), covering:
4 - Profile CRUD (create, get_by_username, get_by_user_id, update)
5 - Contribution graph shape and zero-commit baseline
6 - get_public_repos filters private repos
7 - get_session_credits baseline (no sessions → 0)
8 """
9 from __future__ import annotations
10
11 import pytest
12 from sqlalchemy.ext.asyncio import AsyncSession
13
14 from musehub.models.musehub import ProfileUpdateRequest
15 from musehub.services import musehub_profile
16 from tests.factories import create_profile, create_repo
17 from muse.core.types import long_id
18
19
20 # ---------------------------------------------------------------------------
21 # create_profile / get_profile_by_username
22 # ---------------------------------------------------------------------------
23
24 async def test_create_profile_and_get_by_username(db_session: AsyncSession) -> None:
25 profile = await create_profile(db_session, username="artistone", display_name="Artist One")
26 found = await musehub_profile.get_profile_by_username(db_session, "artistone")
27 assert found is not None
28 assert found.handle == "artistone"
29 assert found.display_name == "Artist One"
30
31
32 async def test_get_profile_by_username_missing_returns_none(db_session: AsyncSession) -> None:
33 result = await musehub_profile.get_profile_by_username(db_session, "ghost-user")
34 assert result is None
35
36
37 async def test_get_profile_by_user_id(db_session: AsyncSession) -> None:
38 profile = await create_profile(db_session, username="byid-user")
39 found = await musehub_profile.get_profile_by_user_id(db_session, profile.identity_id)
40 assert found is not None
41 assert found.handle == "byid-user"
42
43
44 async def test_get_profile_by_user_id_missing_returns_none(db_session: AsyncSession) -> None:
45 result = await musehub_profile.get_profile_by_user_id(db_session, "00000000-dead-beef-0000-000000000000")
46 assert result is None
47
48
49 # ---------------------------------------------------------------------------
50 # update_profile
51 # ---------------------------------------------------------------------------
52
53 async def test_update_profile_bio(db_session: AsyncSession) -> None:
54 orm_profile = await create_profile(db_session, username="bio-user", bio="old bio")
55 await musehub_profile.update_profile(
56 db_session,
57 orm_profile,
58 ProfileUpdateRequest(bio="new bio"),
59 )
60 updated = await musehub_profile.get_profile_by_username(db_session, "bio-user")
61 assert updated is not None
62 assert updated.bio == "new bio"
63
64
65 async def test_update_profile_display_name(db_session: AsyncSession) -> None:
66 orm_profile = await create_profile(db_session, username="name-user", display_name="Old Name")
67 await musehub_profile.update_profile(
68 db_session,
69 orm_profile,
70 ProfileUpdateRequest(display_name="New Name"),
71 )
72 updated = await musehub_profile.get_profile_by_username(db_session, "name-user")
73 assert updated is not None
74 assert updated.display_name == "New Name"
75
76
77 # ---------------------------------------------------------------------------
78 # get_public_repos
79 # ---------------------------------------------------------------------------
80
81 async def test_get_public_repos_returns_public_only(db_session: AsyncSession) -> None:
82 profile = await create_profile(db_session, username="pub-repo-user")
83 await create_repo(
84 db_session,
85 owner="pub-repo-user",
86 owner_user_id=profile.identity_id,
87 slug="public-one",
88 visibility="public",
89 )
90 await create_repo(
91 db_session,
92 owner="pub-repo-user",
93 owner_user_id=profile.identity_id,
94 slug="private-one",
95 visibility="private",
96 )
97
98 repos = await musehub_profile.get_public_repos(db_session, profile.handle)
99 slugs = [r.slug for r in repos]
100 assert "public-one" in slugs
101 assert "private-one" not in slugs
102
103
104 async def test_get_public_repos_empty_for_no_repos(db_session: AsyncSession) -> None:
105 profile = await create_profile(db_session, username="no-repos-user")
106 repos = await musehub_profile.get_public_repos(db_session, profile.handle)
107 assert repos == []
108
109
110 # ---------------------------------------------------------------------------
111 # get_session_credits
112 # ---------------------------------------------------------------------------
113
114 async def test_session_credits_zero_baseline(db_session: AsyncSession) -> None:
115 profile = await create_profile(db_session, username="credit-user")
116 credits = await musehub_profile.get_session_credits(db_session, profile.handle)
117 assert credits == 0
118
119
120 # ---------------------------------------------------------------------------
121 # get_full_profile
122 # ---------------------------------------------------------------------------
123
124 async def test_get_full_profile_returns_structured_response(db_session: AsyncSession) -> None:
125 profile = await create_profile(
126 db_session,
127 username="full-profile-user",
128 bio="Full profile bio",
129 display_name="Full User",
130 )
131 result = await musehub_profile.get_full_profile(db_session, "full-profile-user")
132
133 assert result is not None
134 assert result.username == "full-profile-user"
135 assert result.bio == "Full profile bio"
136 assert result.display_name == "Full User"
137 assert isinstance(result.repos, list)
138 assert result.session_credits == 0
139
140
141 async def test_get_full_profile_missing_returns_none(db_session: AsyncSession) -> None:
142 result = await musehub_profile.get_full_profile(db_session, "nobody-at-all")
143 assert result is None
144
145
146 # ---------------------------------------------------------------------------
147 # get_public_repos — domain field
148 # ---------------------------------------------------------------------------
149
150 async def test_get_public_repos_domain_defaults_to_code(db_session: AsyncSession) -> None:
151 profile = await create_profile(db_session, username="domain-default-user")
152 await create_repo(
153 db_session,
154 owner="domain-default-user",
155 owner_user_id=profile.identity_id,
156 slug="no-domain-repo",
157 visibility="public",
158 )
159 repos = await musehub_profile.get_public_repos(db_session, profile.handle)
160 assert len(repos) == 1
161 assert repos[0].domain == "code"
162
163
164 async def test_get_public_repos_preserves_explicit_domain(db_session: AsyncSession) -> None:
165 """domain_id is a plain string label — no musehub_domains join required."""
166 profile = await create_profile(db_session, username="domain-explicit-user")
167 await create_repo(
168 db_session,
169 owner="domain-explicit-user",
170 owner_user_id=profile.identity_id,
171 slug="midi-repo",
172 visibility="public",
173 domain_id="midi",
174 )
175 repos = await musehub_profile.get_public_repos(db_session, profile.handle)
176 assert len(repos) == 1
177 assert repos[0].domain == "midi"
178
179
180 # ---------------------------------------------------------------------------
181 # build_activity_canvas — domain-driven, no phantom rows
182 # ---------------------------------------------------------------------------
183
184 async def test_canvas_empty_for_user_with_no_repos(db_session: AsyncSession) -> None:
185 """Canvas returns [] for a user with no repos — no phantom domain rows."""
186 profile = await create_profile(db_session, username="no-repos-canvas-user")
187 canvas = await musehub_profile.build_activity_canvas(db_session, profile.handle)
188 assert canvas == []
189
190
191 async def test_canvas_shows_only_domains_with_commits(db_session: AsyncSession) -> None:
192 """Canvas only includes domains where the user has commits. domain_id is a plain string."""
193 import secrets
194 from datetime import datetime, timezone, timedelta
195 from musehub.db import musehub_models as db_m
196 from musehub.core.genesis import compute_repo_id, compute_identity_id
197
198 handle = f"canvas-real-{secrets.token_hex(4)}"
199 await create_profile(db_session, username=handle)
200 owner_id = compute_identity_id(handle.encode())
201 ts = datetime.now(tz=timezone.utc) - timedelta(days=5)
202
203 # Code repo with one commit — must appear
204 code_repo = db_m.MusehubRepo(
205 repo_id=compute_repo_id(owner_id, "code-proj", "code", ts.isoformat()),
206 name="code-proj", owner=handle, slug="code-proj",
207 visibility="public", owner_user_id=owner_id, domain_id="code",
208 )
209 db_session.add(code_repo)
210 db_session.add(db_m.MusehubCommit(
211 commit_id=long_id(secrets.token_hex(32)),
212 repo_id=code_repo.repo_id, branch="main", parent_ids=[],
213 author=handle, message="init", timestamp=ts,
214 ))
215
216 # Midi repo with no commits — must NOT appear
217 midi_repo = db_m.MusehubRepo(
218 repo_id=compute_repo_id(owner_id, "midi-proj", "midi", ts.isoformat()),
219 name="midi-proj", owner=handle, slug="midi-proj",
220 visibility="public", owner_user_id=owner_id, domain_id="midi",
221 )
222 db_session.add(midi_repo)
223 await db_session.commit()
224
225 canvas = await musehub_profile.build_activity_canvas(db_session, handle)
226 domain_names = [d.domain for d in canvas]
227 assert "code" in domain_names, "code domain must appear — it has commits"
228 assert "midi" not in domain_names, "midi domain must not appear — no commits"
229
230
231 async def test_canvas_null_domain_id_counts_as_code(db_session: AsyncSession) -> None:
232 """Repos with domain_id=null must appear in the 'code' bucket on the canvas."""
233 import secrets
234 from datetime import datetime, timezone, timedelta
235 from musehub.db import musehub_models as db_m
236 from musehub.core.genesis import compute_repo_id, compute_identity_id
237
238 handle = f"canvas-null-{secrets.token_hex(4)}"
239 await create_profile(db_session, username=handle)
240 owner_id = compute_identity_id(handle.encode())
241 ts = datetime.now(tz=timezone.utc) - timedelta(days=3)
242
243 repo = db_m.MusehubRepo(
244 repo_id=compute_repo_id(owner_id, "legacy-repo", "", ts.isoformat()),
245 name="legacy-repo", owner=handle, slug="legacy-repo",
246 visibility="public", owner_user_id=owner_id, domain_id=None,
247 )
248 db_session.add(repo)
249 db_session.add(db_m.MusehubCommit(
250 commit_id=long_id(secrets.token_hex(32)),
251 repo_id=repo.repo_id, branch="main", parent_ids=[],
252 author=handle, message="init", timestamp=ts,
253 ))
254 await db_session.commit()
255
256 canvas = await musehub_profile.build_activity_canvas(db_session, handle)
257 domain_names = [d.domain for d in canvas]
258 assert "code" in domain_names, "null domain_id repos must count toward 'code'"
259
260
261 async def test_canvas_excludes_internal_domains(db_session: AsyncSession) -> None:
262 """identity and social repos never appear in the activity canvas."""
263 import secrets
264 from datetime import datetime, timezone, timedelta
265 from musehub.db import musehub_models as db_m
266 from musehub.core.genesis import compute_repo_id, compute_identity_id
267
268 handle = f"canvas-internal-{secrets.token_hex(4)}"
269 await create_profile(db_session, username=handle)
270 owner_id = compute_identity_id(handle.encode())
271
272 ts = datetime.now(tz=timezone.utc) - timedelta(days=2)
273 for domain_id in ("identity", "social"):
274 repo = db_m.MusehubRepo(
275 repo_id=compute_repo_id(owner_id, f"{domain_id}-repo", domain_id, ts.isoformat()),
276 name=f"{domain_id}-repo", owner=handle, slug=f"{domain_id}-repo",
277 visibility="private", owner_user_id=owner_id, domain_id=domain_id,
278 )
279 db_session.add(repo)
280 db_session.add(db_m.MusehubCommit(
281 commit_id=long_id(secrets.token_hex(32)),
282 repo_id=repo.repo_id, branch="main", parent_ids=[],
283 author=handle, message="sys", timestamp=ts,
284 ))
285 await db_session.commit()
286
287 canvas = await musehub_profile.build_activity_canvas(db_session, handle)
288 domain_names = [d.domain for d in canvas]
289 assert "identity" not in domain_names
290 assert "social" not in domain_names
291
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago