diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go index b3b03e3..a0b7d5b 100644 --- a/backend/internal/account/profile_test.go +++ b/backend/internal/account/profile_test.go @@ -5,22 +5,29 @@ import ( "errors" "strings" "testing" + "time" "github.com/google/uuid" ) // TestUpdateProfileValidation checks that bad fields are rejected before any -// database access, so a nil-backed Store is enough to exercise the guards. +// database access, so a nil-backed Store is enough to exercise the guards. It also +// confirms UpdateProfile wires the Stage 8 validators (name format, away window, +// offset/IANA timezone), not just their unit tests in validate_test.go. func TestUpdateProfileValidation(t *testing.T) { s := &Store{} base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"} + hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } tests := []struct { name string mut func(p *ProfileUpdate) }{ {"unknown language", func(p *ProfileUpdate) { p.PreferredLanguage = "fr" }}, {"invalid timezone", func(p *ProfileUpdate) { p.TimeZone = "Mars/Olympus" }}, + {"bad offset timezone", func(p *ProfileUpdate) { p.TimeZone = "+15:00" }}, {"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }}, + {"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }}, + {"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 7cdd159..105290d 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -7,6 +7,7 @@ import ( "errors" "regexp" "testing" + "time" "github.com/google/uuid" @@ -157,3 +158,26 @@ func TestUpdateProfilePersists(t *testing.T) { t.Errorf("profile did not persist: %+v", reloaded) } } + +// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is +// accepted, persisted verbatim, and resolved to the right fixed offset. +func TestUpdateProfileOffsetTimezone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc := provisionAccount(t) + + updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ + DisplayName: "Kaya", + PreferredLanguage: "en", + TimeZone: "+03:00", + }) + if err != nil { + t.Fatalf("update with offset timezone: %v", err) + } + if updated.TimeZone != "+03:00" { + t.Fatalf("timezone = %q, want +03:00", updated.TimeZone) + } + if _, off := time.Date(2024, 1, 1, 12, 0, 0, 0, account.ResolveZone(updated.TimeZone)).Zone(); off != 3*3600 { + t.Fatalf("ResolveZone offset = %d, want 10800", off) + } +} diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index a21174c..7d53014 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -86,3 +86,65 @@ test('finished game draws an inert footer and trims the live-only menu', async ( await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); }); + +test('lobby hamburger shows the pending notification count', async ({ page }) => { + await loginLobby(page); + // One incoming friend request (Rick) + one invitation (Kaya) = 2. + await expect(page.getByTestId('menu-badge')).toHaveText('2'); +}); + +test('play with friends: a game type is required to send an invitation', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar + await page.getByRole('button', { name: 'Play with friends' }).click(); + + const send = page.getByRole('button', { name: 'Send invitation' }); + await expect(send).toBeDisabled(); + + await page.getByRole('checkbox').first().check(); // pick a friend + await expect(send).toBeDisabled(); // still no game type + + await page.locator('.field select').first().selectOption('english'); + await expect(send).toBeEnabled(); + + await send.click(); // the mock creates it and returns to the lobby + await expect(page.getByText('Active games')).toBeVisible(); +}); + +test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Add to friends: Ann/ }).click(); + // Reopening the menu shows the item as a disabled "request sent". + await page.locator('.burger').first().click(); + const sent = page.getByRole('button', { name: 'Request sent' }); + await expect(sent).toBeVisible(); + await expect(sent).toBeDisabled(); +}); + +test('profile edit disables Save and flags an invalid display name', async ({ page }) => { + await loginLobby(page); + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Profile/ }).click(); + await page.getByRole('button', { name: /Edit profile/ }).click(); + + const name = page.locator('.edit input').first(); + const save = page.getByRole('button', { name: /^Save$/ }); + await name.fill('Bad__Name'); // adjacent specials — invalid + await expect(save).toBeDisabled(); + await expect(name).toHaveClass(/invalid/); + + await name.fill('Good Name'); + await expect(save).toBeEnabled(); +}); + +test('chat send and nudge are icon buttons', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Ann/ }).click(); + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: 'Chat' }).click(); + // Icon-only controls expose their action through the aria-label. + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible(); +});