Reported directly from the container's crash output: TypeError: Cannot
read properties of undefined (reading 'isPaused') inside SocketGateway's
"playstate" handler, taking down the entire Node process - every active
party, not just one connection.
Root cause: party.members is keyed purely by userId, with no per-device
identity yet (the gap the "same account, different device" feature also
needs - see docs/architecture.md). Two connections for the same userId
share one map slot; PartyService.join/create both unconditionally
overwrite it. When the connection currently occupying that slot is the
party's last member and leaves, leave() deletes the entry and - by
design, since there's no successor - never reassigns the now-stale
party.hostUserId. The OTHER connection for that same userId, still fully
connected with its own closure referencing the same now-destroyed Party
object, sends its regular 3s heartbeat next: the host check passes
(stale userId equals stale hostUserId on both sides), then
party.members.get(userId)! finds nothing at all. That's the crash.
Fixed at two levels:
- server/src/realtime/SocketGateway.ts: the "playstate" handler's
unchecked `!` is now a real `if (!member) return`, matching the guard
every other handler already used for the same lookup.
- Every socket.on(...) in attach() now goes through a new safe()
wrapper (log-and-swallow on an unexpected exception) - Socket.IO has
no equivalent of Express's error-handling middleware (asyncRoute()
already covers every REST route for exactly this), so a synchronous
throw from any listener, this one or a future one, would otherwise
keep crashing the whole process outright.
- server/test/socketGateway.test.ts: a regression test reproducing the
exact sequence. Verified it's meaningful, not vacuous, by temporarily
reverting just the specific guard and confirming that same test
correctly fails before restoring the fix.
Deliberately not changing how join/leave share that one map slot in this
pass - that's the real design work the fuller device-detection feature
still needs. This stops it from being able to crash the server in the
meantime, regardless of how that gets resolved.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: the time shown for an ongoing party is the movie's elapsed
time. True, but only by coincidence - the label was actually just
Date.now() - party.createdUtc, which climbs at wall-clock speed
regardless of whether the movie is actually playing. Pause it for five
minutes and the home page keeps counting through the pause the player
itself has frozen.
- server/src/types.ts, client/src/types.ts: LobbyPartyDto gains
positionTicks/isPaused/positionUpdatedUtc - the host's own tracked
playback state (same values SocketGateway already maintains for their
party's own room), null for music parties, where there's no single
"elapsed" across a rotating queue of tracks.
- server/src/realtime/SocketGateway.ts: lobbySnapshot() fills these from
the host's member record for video parties. Two new broadcastLobby()
trigger points to keep it reasonably fresh: an actual pause/play
transition (not every 3s heartbeat resend - same guard already used
for that transition's log line) and advancing the queue (advanceQueue
already reset every member's position for the new item; nothing had
ever pushed that to the lobby before, so a card's title/poster/position
could sit stale until an unrelated join/leave refreshed it).
- client/src/views/home/Home.ts: updateElapsedLabels() now computes a
live, pause-aware position for a video party (frozen while paused,
ticking from position+timestamp while playing) instead of always
falling back to createdUtc - which is still exactly what a music party
(or a party missing these fields) uses.
- server/test/socketGateway.test.ts: two new cases - the lobby snapshot's
video-vs-music shape, and that a transition/next() refreshes it while
an unchanged heartbeat resend doesn't.
Verified against the real bundled client with a faked "parties" push:
a paused party's label held still across a real wait; a playing one, and
a music party on the unchanged createdUtc path, both kept ticking up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- server/test/*.test.ts: formalizes this project's many ad-hoc scratchpad
test scripts into a committed suite, one file per service
(musicQueueService, partyService, socketGateway, catalogService), using
Node's built-in node:test + node:assert/strict - zero new dependencies.
Runs straight against TypeScript source via `tsx --test` (npm test), no
compile step.
- server/tsconfig.test.json: extends the base config, overrides rootDir to
"." and adds test/**/*.ts to include, so `npm run typecheck` covers the
test files too without pulling them into dist/ (npm run build still
uses the unmodified base tsconfig, scoped to src/ only).
- server/package.json: typecheck now runs against tsconfig.test.json; new
test script.
- .forgejo/workflows/publish-docker.yml: new `test` job (node:20-alpine
container - the publish job's docker:27-cli has no Node) running server
typecheck+test and client typecheck+build; publish now depends on it via
needs: test. Removed the stale "no test suite yet" comment.
- README.md, docs/architecture.md: documented the test suite, how to run
it locally, and the new CI job/gating.
Verified locally: server typecheck, server test (25/25 passing), server
build, client typecheck, and client build all pass individually - the
same commands the new CI job runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>