Add clip pinning test coverage

This commit is contained in:
Codex
2026-04-22 08:33:47 -05:00
parent 0a13aedbef
commit 58efca9a9f
9 changed files with 124 additions and 6 deletions

View File

@@ -56,6 +56,7 @@ class AudioClip(Base):
start_ms: Mapped[int] = mapped_column(Integer)
end_ms: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
hidden: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
normalized_path: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

View File

@@ -65,7 +65,10 @@ def list_assignments(
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]:
query = select(GameAssignment).join(GameAssignment.clip).where(GameAssignment.external_game_id == external_game_id)
query = select(GameAssignment).join(GameAssignment.clip).where(
GameAssignment.external_game_id == external_game_id,
AudioClip.hidden.is_(False),
)
if external_player_id:
query = query.where(GameAssignment.external_player_id == external_player_id)
assignments = db.scalars(query.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())).all()
@@ -82,6 +85,8 @@ def create_assignment(
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.normalization_status != "ready":
raise HTTPException(status_code=422, detail="Clip is not ready")
if clip.hidden:
raise HTTPException(status_code=404, detail="Clip not found")
if clip.asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if clip.asset.owner_external_player_id != payload.external_player_id:
@@ -142,7 +147,7 @@ def prepare_game(
assignments = db.scalars(
select(GameAssignment)
.join(GameAssignment.clip)
.where(GameAssignment.external_game_id == external_game_id)
.where(GameAssignment.external_game_id == external_game_id, AudioClip.hidden.is_(False))
.order_by(AudioClip.sort_order.asc(), GameAssignment.updated_at.desc())
).all()
external_team_id = assignments[0].external_team_id if assignments else ""
@@ -192,11 +197,15 @@ def trigger_playback(
assignment = db.get(GameAssignment, payload.assignment_id)
if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Pin not found")
if assignment.clip.hidden:
raise HTTPException(status_code=404, detail="Pin not found")
playback.current_assignment_id = assignment.id
else:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.asset.external_team_id != playback.external_team_id:
raise HTTPException(status_code=404, detail="Clip not found")
if clip.hidden:
raise HTTPException(status_code=404, detail="Clip not found")
if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="Clip does not belong to that player")
playback.current_assignment_id = None

View File

@@ -41,6 +41,7 @@ def clip_to_response(clip: AudioClip) -> AudioClipResponse:
start_ms=clip.start_ms,
end_ms=clip.end_ms,
sort_order=clip.sort_order,
hidden=clip.hidden,
normalization_status=clip.normalization_status,
normalized_url=normalized_url,
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
@@ -340,6 +341,8 @@ def update_clip(
clip.end_ms = payload.end_ms
if payload.sort_order is not None:
clip.sort_order = payload.sort_order
if payload.hidden is not None:
clip.hidden = payload.hidden
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@@ -376,6 +379,7 @@ def delete_clip(
def list_clips(
external_team_id: str,
owner_external_player_id: str | None = None,
include_hidden: bool = False,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioClipResponse]:
@@ -387,6 +391,8 @@ def list_clips(
)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
if not include_hidden:
query = query.where(AudioClip.hidden.is_(False))
clips = db.scalars(query).all()
return [clip_to_response(clip) for clip in clips]

View File

@@ -68,6 +68,7 @@ class AudioClipUpdate(BaseModel):
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
sort_order: int | None = None
hidden: bool | None = None
class AudioClipResponse(BaseModel):
@@ -80,6 +81,7 @@ class AudioClipResponse(BaseModel):
start_ms: int
end_ms: int
sort_order: int
hidden: bool
normalization_status: str
normalized_url: str | None
waveform_duration_ms: int | None = None

View File

@@ -346,6 +346,104 @@ def test_player_can_reorder_clips_in_their_library() -> None:
assert [item["sort_order"] for item in clips.json()] == [0, 1]
def test_hidden_clips_are_removed_from_gameday_views_but_remain_pinnable() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
db = SessionLocal()
session = UserSession(
session_token="player-hidden-session",
provider="teamsnap",
external_team_id="team-hidden",
external_player_id="player-hidden",
)
db.add(session)
db.flush()
asset = AudioAsset(
external_team_id="team-hidden",
owner_external_player_id="player-hidden",
uploaded_by_session_id=session.id,
title="Hidden song",
original_filename="hidden.mp3",
mime_type="audio/mpeg",
size_bytes=123,
storage_path="uploads/hidden.mp3",
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label="Hidden clip",
start_ms=0,
end_ms=10000,
normalization_status="ready",
normalized_path="clips/hidden.mp3",
)
db.add(clip)
db.commit()
db.refresh(clip)
db.close()
client.cookies.set(settings.session_cookie_name, "player-hidden-session")
pin_response = client.post(
"/games/game-hidden/assignments",
json={
"external_team_id": "team-hidden",
"external_player_id": "player-hidden",
"clip_id": clip.id,
"batting_slot": 1,
"status": "ready",
},
)
assert pin_response.status_code == 200
visible_before_hide = client.get(
"/media/clips",
params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"},
)
assert visible_before_hide.status_code == 200
assert [item["id"] for item in visible_before_hide.json()] == [clip.id]
assert visible_before_hide.json()[0]["hidden"] is False
hide_response = client.patch(
f"/media/clips/{clip.id}",
params={"owner_external_player_id": "player-hidden"},
json={"start_ms": 0, "end_ms": 10000, "hidden": True},
)
assert hide_response.status_code == 200
assert hide_response.json()["hidden"] is True
visible_after_hide = client.get(
"/media/clips",
params={"external_team_id": "team-hidden", "owner_external_player_id": "player-hidden"},
)
assert visible_after_hide.status_code == 200
assert visible_after_hide.json() == []
hidden_in_library = client.get(
"/media/clips",
params={
"external_team_id": "team-hidden",
"owner_external_player_id": "player-hidden",
"include_hidden": True,
},
)
assert hidden_in_library.status_code == 200
assert [item["id"] for item in hidden_in_library.json()] == [clip.id]
assert hidden_in_library.json()[0]["hidden"] is True
game_assignments = client.get("/games/game-hidden/assignments")
prep = client.get("/games/game-hidden/prep")
pins = client.get("/games/pins", params={"external_player_id": "player-hidden"})
assert game_assignments.status_code == 200
assert prep.status_code == 200
assert pins.status_code == 200
assert game_assignments.json() == []
assert prep.json()["assignments"] == []
assert [item["clip_id"] for item in pins.json()] == [clip.id]
def test_clip_updates_can_use_player_scoped_authorization() -> None:
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
editor_session = UserSession(session_token="editor-session", provider="teamsnap")

View File

@@ -131,11 +131,11 @@ export const api = {
throw new Error(await response.text());
}
},
listClips: (teamId: string, playerId?: string) =>
listClips: (teamId: string, playerId?: string, includeHidden = false) =>
request<AudioClip[]>(
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
}${includeHidden ? "&include_hidden=true" : ""}`,
),
createClip: (payload: {
asset_id: number;

View File

@@ -47,6 +47,7 @@ export interface AudioClip {
start_ms: number;
end_ms: number;
sort_order: number;
hidden: boolean;
normalization_status: string;
normalized_url?: string | null;
waveform_duration_ms?: number | null;
@@ -59,6 +60,7 @@ export interface AudioClipUpdate {
end_ms: number;
label?: string;
sort_order?: number | null;
hidden?: boolean | null;
}
export interface GameAssignment {

View File

@@ -30,7 +30,7 @@ export function GamePage() {
}, [searchParams, selectedGameId, walkup.nextGame]);
const clipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});

View File

@@ -653,7 +653,7 @@ function LibraryClips({
pinnedAssignmentsByClipId: Map<string, GameAssignment>;
}) {
const fallbackClipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryKey: ["clips", teamId, playerId, "visible"],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});