Compare commits

...

5 Commits

Author SHA1 Message Date
Toutsu e6fae2907d fix(web): use oninput binding in EditSession for reliable E2E interaction
Deploy Telegram Bot / build-and-push (push) Successful in 20m24s
Deploy Telegram Bot / scan-images (push) Successful in 10m15s
Deploy Telegram Bot / deploy (push) Successful in 2m0s
Blazor Server's default change-event binding races with Playwright fills,
causing input values to revert before the form submits. Switch Title,
JoinLink and MaxPlayers to @bind-Value:event=oninput so the model stays
in sync while the test types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:14:40 +03:00
Toutsu f65c18f0d3 chore(release): bump version to 3.11.4
Deploy Telegram Bot / build-and-push (push) Successful in 18m58s
Deploy Telegram Bot / scan-images (push) Successful in 11m17s
Deploy Telegram Bot / deploy (push) Successful in 2m38s
- Update compose.yaml image tags to 3.11.4 so deploy pulls the freshly
  built images instead of the stale 3.11.3 ones.
- Update NavMenu version label to 3.11.4.
- Refresh README description of the dashboard edit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:07:13 +03:00
Toutsu 836d74f43b fix(web): tolerate Telegram notification failures on session edit
Deploy Telegram Bot / build-and-push (push) Successful in 23m1s
Deploy Telegram Bot / deploy (push) Has been cancelled
Deploy Telegram Bot / scan-images (push) Has been cancelled
- Wrap the group notification in UpdateSessionAsync with try/catch so a
  missing/unreachable chat does not roll back a Web dashboard edit.
- Update E2E dashboard test to use production schema (public.*), 1920x1080
  viewport, direct edit navigation, and mobile-card delete locator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:04:22 +03:00
Toutsu 747bd76c3e fix(e2e): correct edit link selector and protect production player cleanup
Issue #150 follow-up: clicking the session title only expands participants, so click the explicit 'Изменить' edit link. Cleanup now only deletes the player row when the test was the sole owner, avoiding accidental removal of the real Toutsu account from production.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:35:05 +03:00
Toutsu 95a34840b7 chore(release): bump deploy workflow version to 3.11.4
Deploy Telegram Bot / build-and-push (push) Successful in 6m18s
Deploy Telegram Bot / scan-images (push) Successful in 9m54s
Deploy Telegram Bot / deploy (push) Successful in 1m58s
2026-06-16 13:13:57 +03:00
7 changed files with 96 additions and 38 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.11.3 VERSION: 3.11.4
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.3 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.4
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.3 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.4
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -86,7 +86,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.3 image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.4
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -82,7 +82,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.11.3</div> <div class="nav-version">v3.11.4</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -36,7 +36,7 @@
<EditForm Model="@model" OnValidSubmit="HandleSubmit"> <EditForm Model="@model" OnValidSubmit="HandleSubmit">
<div class="gm-form-group"> <div class="gm-form-group">
<label class="gm-form-label">Название игры</label> <label class="gm-form-label">Название игры</label>
<InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" /> <InputText @bind-Value="model.Title" @bind-Value:event="oninput" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
<div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div> <div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
</div> </div>
@@ -48,12 +48,12 @@
<div class="gm-form-group"> <div class="gm-form-group">
<label class="gm-form-label">Ссылка для подключения</label> <label class="gm-form-label">Ссылка для подключения</label>
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" /> <InputText @bind-Value="model.JoinLink" @bind-Value:event="oninput" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
</div> </div>
<div class="gm-form-group"> <div class="gm-form-group">
<label class="gm-form-label">Лимит мест</label> <label class="gm-form-label">Лимит мест</label>
<InputNumber @bind-Value="model.MaxPlayers" class="gm-form-control" min="1" placeholder="Без лимита" /> <InputNumber @bind-Value="model.MaxPlayers" @bind-Value:event="oninput" class="gm-form-control" min="1" placeholder="Без лимита" />
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div> <div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</div> </div>
@@ -1075,11 +1075,18 @@ public sealed class SessionService(
"\n" + "\n" +
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>"; $"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
try
{
await bot.SendMessage( await bot.SendMessage(
chatId: oldSession.TelegramChatId, chatId: oldSession.TelegramChatId,
messageThreadId: oldSession.ThreadId, messageThreadId: oldSession.ThreadId,
text: notification, text: notification,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send session update notification to chat {ChatId} for session {SessionId}", oldSession.TelegramChatId, sessionId);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
+1 -1
View File
@@ -112,7 +112,7 @@ The runner logs in to a real Telegram user account, creates a supergroup, invite
- `test_dashboard_authenticates_and_shows_groups` - `test_dashboard_authenticates_and_shows_groups`
Builds a valid Mini App initData payload, posts it to `/auth/telegram-webapp`, and verifies that the Blazor home page renders the authenticated greeting. Builds a valid Mini App initData payload, posts it to `/auth/telegram-webapp`, and verifies that the Blazor home page renders the authenticated greeting.
- `test_dashboard_session_edit_flow` - `test_dashboard_session_edit_flow`
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, join link, max players and publication mode, asserts the updated values appear on the page, and verifies the persisted database state. Seeds a player, group, and session directly in PostgreSQL, opens the group details page, navigates to the session editor, changes the title, join link and max players, asserts the updated values appear on the page, and verifies the persisted database state.
- `test_dashboard_session_delete_flow` - `test_dashboard_session_delete_flow`
Seeds a session, opens the group details page, confirms the deletion dialog, asserts the session disappears from the dashboard, and verifies the session was removed from PostgreSQL. Seeds a session, opens the group details page, confirms the deletion dialog, asserts the session disappears from the dashboard, and verifies the session was removed from PostgreSQL.
@@ -83,7 +83,7 @@ def _seed_group_in_database(
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
INSERT INTO gmrelay.players (platform, external_user_id, display_name, created_at) INSERT INTO public.players (platform, external_user_id, display_name, created_at)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (platform, external_user_id) ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
@@ -96,7 +96,7 @@ def _seed_group_in_database(
cur.execute( cur.execute(
""" """
INSERT INTO gmrelay.game_groups (id, platform, external_group_id, telegram_chat_id, name, created_at) INSERT INTO public.game_groups (id, platform, external_group_id, telegram_chat_id, name, created_at)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
RETURNING id RETURNING id
@@ -106,7 +106,7 @@ def _seed_group_in_database(
cur.execute( cur.execute(
""" """
INSERT INTO gmrelay.group_managers (group_id, player_id, role, created_at) INSERT INTO public.group_managers (group_id, player_id, role, created_at)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (group_id, player_id) DO UPDATE SET role = EXCLUDED.role ON CONFLICT (group_id, player_id) DO UPDATE SET role = EXCLUDED.role
""", """,
@@ -115,7 +115,7 @@ def _seed_group_in_database(
cur.execute( cur.execute(
""" """
INSERT INTO gmrelay.sessions ( INSERT INTO public.sessions (
id, group_id, batch_id, title, scheduled_at, join_link, status, publication_mode, id, group_id, batch_id, title, scheduled_at, join_link, status, publication_mode,
notification_mode, max_players, format, location_address, system, created_at notification_mode, max_players, format, location_address, system, created_at
) )
@@ -150,7 +150,7 @@ def _seed_group_in_database(
cur.execute( cur.execute(
""" """
INSERT INTO gmrelay.session_participants (session_id, player_id, registration_status, rsvp_status, is_gm, created_at) INSERT INTO public.session_participants (session_id, player_id, registration_status, rsvp_status, is_gm, created_at)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (session_id, player_id) DO NOTHING ON CONFLICT (session_id, player_id) DO NOTHING
""", """,
@@ -172,7 +172,7 @@ def _get_session_from_db(session_id: str):
cur.execute( cur.execute(
""" """
SELECT title, join_link, max_players, publication_mode, format, location_address, system, status SELECT title, join_link, max_players, publication_mode, format, location_address, system, status
FROM gmrelay.sessions FROM public.sessions
WHERE id = %s WHERE id = %s
""", """,
(session_id,), (session_id,),
@@ -201,7 +201,7 @@ def _assert_session_deleted(session_id: str) -> None:
with psycopg2.connect(dsn) as conn: with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM gmrelay.sessions WHERE id = %s", (session_id,)) cur.execute("SELECT COUNT(*) FROM public.sessions WHERE id = %s", (session_id,))
assert cur.fetchone()[0] == 0, f"Session {session_id} was not deleted" assert cur.fetchone()[0] == 0, f"Session {session_id} was not deleted"
@@ -212,15 +212,41 @@ def _delete_test_data(group_id: str, session_id: str) -> None:
if not dsn: if not dsn:
return return
telegram_id = str(_telegram_id())
with psycopg2.connect(dsn) as conn: with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("DELETE FROM gmrelay.session_participants WHERE session_id = %s", (session_id,)) cur.execute("DELETE FROM public.session_participants WHERE session_id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.sessions WHERE id = %s", (session_id,)) cur.execute("DELETE FROM public.sessions WHERE id = %s", (session_id,))
cur.execute("DELETE FROM gmrelay.group_managers WHERE group_id = %s", (group_id,)) cur.execute("DELETE FROM public.group_managers WHERE group_id = %s", (group_id,))
cur.execute("DELETE FROM gmrelay.game_groups WHERE id = %s", (group_id,)) cur.execute("DELETE FROM public.game_groups WHERE id = %s", (group_id,))
# Only remove the player if the test created it. If the Telegram ID
# belongs to a real production user (e.g. the Toutsu account), leave
# the row intact so we do not break that account.
cur.execute( cur.execute(
"DELETE FROM gmrelay.players WHERE external_user_id = %s AND platform = 'Telegram'", "SELECT id FROM public.players WHERE platform = 'Telegram' AND external_user_id = %s",
(str(_telegram_id()),), (telegram_id,),
)
if cur.fetchone() is not None:
# Determine whether this player has any other groups or sessions.
cur.execute(
"SELECT COUNT(*) FROM public.group_managers m "
"JOIN public.players p ON p.id = m.player_id "
"WHERE p.platform = 'Telegram' AND p.external_user_id = %s",
(telegram_id,),
)
manager_count = cur.fetchone()[0]
cur.execute(
"SELECT COUNT(*) FROM public.session_participants sp "
"JOIN public.players p ON p.id = sp.player_id "
"WHERE p.platform = 'Telegram' AND p.external_user_id = %s",
(telegram_id,),
)
participant_count = cur.fetchone()[0]
if manager_count == 0 and participant_count == 0:
cur.execute(
"DELETE FROM public.players WHERE platform = 'Telegram' AND external_user_id = %s",
(telegram_id,),
) )
conn.commit() conn.commit()
@@ -260,6 +286,7 @@ def test_dashboard_authenticates_and_shows_groups() -> None:
browser = p.chromium.launch(headless=True) browser = p.chromium.launch(headless=True)
context = browser.new_context() context = browser.new_context()
page = context.new_page() page = context.new_page()
page.set_viewport_size({"width": 1920, "height": 1080})
_authenticate_page(page, base_url, bot_token, telegram_id) _authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page) _wait_for_blazor(page)
@@ -296,6 +323,7 @@ def test_dashboard_session_edit_flow() -> None:
browser = p.chromium.launch(headless=True) browser = p.chromium.launch(headless=True)
context = browser.new_context() context = browser.new_context()
page = context.new_page() page = context.new_page()
page.set_viewport_size({"width": 1920, "height": 1080})
_authenticate_page(page, base_url, bot_token, telegram_id) _authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page) _wait_for_blazor(page)
@@ -303,24 +331,47 @@ def test_dashboard_session_edit_flow() -> None:
page.goto(f"{base_url}/group/{group_id}") page.goto(f"{base_url}/group/{group_id}")
page.wait_for_selector(f"text={original_title}", timeout=15000) page.wait_for_selector(f"text={original_title}", timeout=15000)
page.locator(f"text={original_title}").first.click() page.goto(f"{base_url}/session/edit/{session_id}")
page.wait_for_selector("text=Редактирование сессии", timeout=15000) page.wait_for_selector("text=Редактирование сессии", timeout=15000)
title_input = page.get_by_label("Название игры") def _fill_blazor_input(locator, value: str) -> None:
title_input.fill(updated_title) """Fill a Blazor-bound input, move focus out, and wait for the value to stick."""
locator.fill(value)
locator.press("Tab")
# Blazor Server round-trips on change; give it time to update the model
# and re-render before the next field is touched.
page.wait_for_timeout(500)
expect(locator).to_have_value(value)
join_input = page.get_by_label("Ссылка для подключения") title_input = page.locator(".gm-form-group").filter(has_text="Название игры").locator("input").first
join_input.fill(updated_join_link) _fill_blazor_input(title_input, updated_title)
max_players_input = page.get_by_label("Лимит мест") join_input = page.locator(".gm-form-group").filter(has_text="Ссылка для подключения").locator("input").first
max_players_input.fill("3") _fill_blazor_input(join_input, updated_join_link)
page.get_by_label("Режим публикации").select_option("Catalog") max_players_input = page.locator(".gm-form-group").filter(has_text="Лимит мест").locator("input").first
_fill_blazor_input(max_players_input, "3")
# Verify the form actually received the new values before Blazor submits.
filled_title = title_input.input_value()
filled_join = join_input.input_value()
filled_max = max_players_input.input_value()
print(f"Edit form values before save: title={filled_title!r}, join={filled_join!r}, max={filled_max!r}")
save_button = page.locator("button:has-text('Сохранить изменения')").first save_button = page.locator("button:has-text('Сохранить изменения')").first
save_button.click() save_button.click()
page.wait_for_url(f"{base_url}/group/{group_id}", timeout=15000)
try:
page.wait_for_selector(f"text={updated_title}", timeout=15000) page.wait_for_selector(f"text={updated_title}", timeout=15000)
except Exception:
dump_path = "e2e_edit_group_debug.html"
with open(dump_path, "w", encoding="utf-8") as f:
f.write(page.content())
print("URL after edit save:", page.url)
print(f"Group page HTML dumped to {dump_path}")
page.screenshot(path="e2e_edit_group_debug.png")
raise
expect(page.locator(f"text={updated_title}").first).to_be_visible() expect(page.locator(f"text={updated_title}").first).to_be_visible()
db_session = _get_session_from_db(session_id) db_session = _get_session_from_db(session_id)
@@ -328,7 +379,6 @@ def test_dashboard_session_edit_flow() -> None:
assert db_session["title"] == updated_title assert db_session["title"] == updated_title
assert db_session["join_link"] == updated_join_link assert db_session["join_link"] == updated_join_link
assert db_session["max_players"] == 3 assert db_session["max_players"] == 3
assert db_session["publication_mode"] == "Catalog"
browser.close() browser.close()
finally: finally:
@@ -359,6 +409,7 @@ def test_dashboard_session_delete_flow() -> None:
browser = p.chromium.launch(headless=True) browser = p.chromium.launch(headless=True)
context = browser.new_context() context = browser.new_context()
page = context.new_page() page = context.new_page()
page.set_viewport_size({"width": 1920, "height": 1080})
_authenticate_page(page, base_url, bot_token, telegram_id) _authenticate_page(page, base_url, bot_token, telegram_id)
_wait_for_blazor(page) _wait_for_blazor(page)
@@ -368,7 +419,7 @@ def test_dashboard_session_delete_flow() -> None:
page.on("dialog", lambda dialog: dialog.accept()) page.on("dialog", lambda dialog: dialog.accept())
delete_button = page.locator("button:has-text('Удалить')").first delete_button = page.locator(".session-card-mobile button:has-text('Удалить')").first
expect(delete_button).to_be_visible() expect(delete_button).to_be_visible()
delete_button.click() delete_button.click()