Rename project and remove unused services
Update project name from teaboy-os to tx-os and remove obsolete services. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3154f23a-748a-4118-aa41-fc01b7b1f04d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/PVuelRZ Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// UI smoke: admin creates a group, assigns a user + a restricted app to it,
|
||||
// then a regular user (granted access via that group) sees the restricted
|
||||
// app in /api/apps. Cleans up after itself.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run this test");
|
||||
}
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdAppIds = [];
|
||||
const createdGroupNames = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, opts = {}) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
const roleName = opts.admin ? "admin" : "user";
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`,
|
||||
[id, roleName],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function createRestrictedApp(prefix) {
|
||||
const slug = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO apps (name_ar, name_en, slug, icon_name, route, color, sort_order, description_en)
|
||||
VALUES ($1, $2, $3, 'Grid2X2', '/test', '#000000', 999, 'visibility test app')
|
||||
RETURNING id`,
|
||||
[`تطبيق ${prefix}`, `App ${prefix}`, slug],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdAppIds.push(id);
|
||||
// Mark the app as restricted by attaching a permission that the regular
|
||||
// 'user' role does NOT have. Pick any permission held by 'admin' but not
|
||||
// by 'user'; if none exists, fall back to the first permission.
|
||||
const permRows = await pool.query(
|
||||
`SELECT id FROM permissions
|
||||
WHERE id IN (SELECT permission_id FROM role_permissions
|
||||
WHERE role_id = (SELECT id FROM roles WHERE name = 'admin'))
|
||||
AND id NOT IN (SELECT permission_id FROM role_permissions
|
||||
WHERE role_id = (SELECT id FROM roles WHERE name = 'user'))
|
||||
LIMIT 1`,
|
||||
);
|
||||
const fallbackRows = permRows.rows.length
|
||||
? permRows.rows
|
||||
: (await pool.query(`SELECT id FROM permissions LIMIT 1`)).rows;
|
||||
if (fallbackRows.length === 0) {
|
||||
throw new Error("No permissions exist in DB; cannot mark app as restricted");
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
||||
[id, fallbackRows[0].id],
|
||||
);
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdGroupNames.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM groups WHERE name = ANY($1::text[])`,
|
||||
[createdGroupNames],
|
||||
);
|
||||
}
|
||||
if (createdAppIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM apps WHERE id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_groups WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function login(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe("Admin: create group + assign user/app → user sees restricted app", () => {
|
||||
test("end-to-end UI flow", async ({ page }) => {
|
||||
const admin = await createUser("vis_admin", { admin: true });
|
||||
const member = await createUser("vis_member");
|
||||
const app = await createRestrictedApp("vis");
|
||||
const groupName = `Vis Smoke ${uniqueSuffix()}`;
|
||||
createdGroupNames.push(groupName);
|
||||
|
||||
// 1) Admin logs in via UI and navigates to the Groups admin section.
|
||||
await login(page, admin.username, TEST_PASSWORD);
|
||||
await page.goto("/admin#section=groups");
|
||||
|
||||
// The "User Management" nav group should auto-expand because a child is active.
|
||||
await expect(page.locator('[data-testid="create-group-btn"]')).toBeVisible();
|
||||
|
||||
// 2) Create the group.
|
||||
await page.locator('[data-testid="create-group-btn"]').click();
|
||||
await page.locator('[data-testid="group-name-input"]').fill(groupName);
|
||||
await page.locator('[data-testid="create-group-submit"]').click();
|
||||
|
||||
// The new group card should appear.
|
||||
const newCard = page
|
||||
.locator('[data-testid^="group-card-"]')
|
||||
.filter({ hasText: groupName });
|
||||
await expect(newCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 3) Open the edit dialog for the new group.
|
||||
await newCard.locator('[data-testid^="edit-group-"]').click();
|
||||
|
||||
// 4) Apps tab → check the restricted app.
|
||||
await page.locator('[data-testid="group-tab-apps"]').click();
|
||||
const appLabel = page.locator("label").filter({ hasText: app.slug });
|
||||
await expect(appLabel).toBeVisible();
|
||||
await appLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// 5) Users tab → check the member user.
|
||||
await page.locator('[data-testid="group-tab-users"]').click();
|
||||
await page.locator('[data-testid="group-user-search"]').fill(member.username);
|
||||
const memberLabel = page.locator("label").filter({ hasText: member.username });
|
||||
await expect(memberLabel).toBeVisible();
|
||||
await memberLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// 6) Save.
|
||||
await page.locator('[data-testid="save-group"]').click();
|
||||
|
||||
// Wait for the save to settle by re-opening the card and checking counts,
|
||||
// or just give the network a moment.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 7) Logout (clear cookies) and login as the member.
|
||||
await page.context().clearCookies();
|
||||
await login(page, member.username, TEST_PASSWORD);
|
||||
|
||||
// 8) Verify visibility through the API as the member's session.
|
||||
const apiRes = await page.request.get(`${API_BASE}/api/apps`);
|
||||
expect(apiRes.status()).toBe(200);
|
||||
const apps = await apiRes.json();
|
||||
const ids = apps.map((a) => a.id);
|
||||
expect(ids).toContain(app.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// UI test verifying that the home-page clock widget keeps its grid position
|
||||
// and compact/large size after a full page reload, and that the changes are
|
||||
// persisted to localStorage under the documented keys
|
||||
// `tx:home-clock-position` / `tx:home-clock-size` (or their per-user
|
||||
// variants once a user is logged in).
|
||||
//
|
||||
// This is the recurring automated counterpart to the manual e2e check
|
||||
// described in .local/tasks/task-57.md. It guards against silent regressions
|
||||
// in:
|
||||
// - artifacts/tx-os/src/pages/home.tsx (drag/long-press wiring)
|
||||
// - artifacts/tx-os/src/components/clock.tsx (useHomeClock{Position,Size})
|
||||
//
|
||||
// Run with:
|
||||
// DATABASE_URL=... pnpm --filter @workspace/tx-os exec playwright test
|
||||
//
|
||||
// The test seeds its own user via the database and cleans up after itself, so
|
||||
// it can run against the live dev environment without polluting it.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run the home-clock UI test");
|
||||
}
|
||||
|
||||
// Same convention as the other UI specs: precomputed bcrypt hash of the
|
||||
// literal password "TestPass123!".
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_app_orders WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function loginViaUi(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
// The clock tile has aria-label="Clock" on its inner icon; the sortable
|
||||
// wrapper is its parent flex container. We grab the wrapper because that is
|
||||
// the element that owns the dnd-kit pointer listeners.
|
||||
function clockTile(page) {
|
||||
return page.locator('[aria-label="Clock"]').first().locator("..");
|
||||
}
|
||||
|
||||
// Read the per-user localStorage values written by useHomeClock{Position,Size}.
|
||||
async function readClockStorage(page, userId) {
|
||||
return page.evaluate((uid) => {
|
||||
return {
|
||||
position: window.localStorage.getItem(
|
||||
`tx:home-clock-position:${uid}`,
|
||||
),
|
||||
size: window.localStorage.getItem(`tx:home-clock-size:${uid}`),
|
||||
};
|
||||
}, userId);
|
||||
}
|
||||
|
||||
// Simulate a real pointer drag that satisfies dnd-kit's PointerSensor
|
||||
// activation distance (8px) before reaching the destination. We move in a few
|
||||
// steps so dnd-kit's collision detection has time to react.
|
||||
async function dragWithMouse(page, fromBox, toBox) {
|
||||
const fromX = fromBox.x + fromBox.width / 2;
|
||||
const fromY = fromBox.y + fromBox.height / 2;
|
||||
const toX = toBox.x + toBox.width / 2;
|
||||
const toY = toBox.y + toBox.height / 2;
|
||||
|
||||
await page.mouse.move(fromX, fromY);
|
||||
await page.mouse.down();
|
||||
// Pass the 8px activation threshold before sliding to the target.
|
||||
await page.mouse.move(fromX + 12, fromY + 12, { steps: 4 });
|
||||
await page.mouse.move(toX, toY, { steps: 12 });
|
||||
// Hover briefly so SortableContext registers the new index.
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Home clock widget: position + size persistence", () => {
|
||||
test("position and size survive a full reload", async ({ page }) => {
|
||||
const user = await createUser("home_clock_persist");
|
||||
await loginViaUi(page, user.username, TEST_PASSWORD);
|
||||
|
||||
// Land on home and wait for the clock + at least one app icon to render.
|
||||
await page.goto("/");
|
||||
await expect(clockTile(page)).toBeVisible({ timeout: 15_000 });
|
||||
const appButtons = page.locator(
|
||||
'button.flex.flex-col.items-center.gap-2.group',
|
||||
);
|
||||
await expect(appButtons.first()).toBeVisible();
|
||||
const appCount = await appButtons.count();
|
||||
expect(
|
||||
appCount,
|
||||
"need at least 2 app tiles to have a distinct drop target for the clock",
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Sanity check: starting position defaults to 0 (no key written yet).
|
||||
const initial = await readClockStorage(page, user.id);
|
||||
expect(initial.position).toBeNull();
|
||||
expect(initial.size).toBeNull();
|
||||
|
||||
// ---- Drag the clock to a later position in the grid ----------------
|
||||
// Pick a target tile that is meaningfully far from the clock so dnd-kit
|
||||
// is forced to reorder.
|
||||
const targetIndex = Math.min(appCount - 1, 3);
|
||||
const sourceBox = await clockTile(page).boundingBox();
|
||||
const targetBox = await appButtons.nth(targetIndex).boundingBox();
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error("Could not measure clock or target tile bounding box");
|
||||
}
|
||||
await dragWithMouse(page, sourceBox, targetBox);
|
||||
|
||||
// Wait for the position write to land in localStorage.
|
||||
await expect
|
||||
.poll(async () => (await readClockStorage(page, user.id)).position, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.not.toBeNull();
|
||||
const afterDrag = await readClockStorage(page, user.id);
|
||||
const draggedPosition = Number(afterDrag.position);
|
||||
expect(Number.isFinite(draggedPosition)).toBe(true);
|
||||
expect(draggedPosition).toBeGreaterThan(0);
|
||||
|
||||
// ---- Long-press the clock to flip it to the large size -------------
|
||||
const clockBox = await clockTile(page).boundingBox();
|
||||
if (!clockBox) throw new Error("Could not measure clock tile after drag");
|
||||
const cx = clockBox.x + clockBox.width / 2;
|
||||
const cy = clockBox.y + clockBox.height / 2;
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.down();
|
||||
// The long-press timer fires at 500ms; hold a bit longer to be safe and
|
||||
// do NOT move (movement >8px aborts the long-press in home.tsx).
|
||||
await page.waitForTimeout(750);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readClockStorage(page, user.id)).size, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toBe("large");
|
||||
|
||||
// The large clock tile spans 2 columns / 2 rows in the grid.
|
||||
await expect(clockTile(page)).toHaveClass(/col-span-2/);
|
||||
await expect(clockTile(page)).toHaveClass(/row-span-2/);
|
||||
|
||||
const beforeReload = await readClockStorage(page, user.id);
|
||||
expect(beforeReload.position).toBe(String(draggedPosition));
|
||||
expect(beforeReload.size).toBe("large");
|
||||
|
||||
// ---- Reload and assert both values stuck ---------------------------
|
||||
await page.reload();
|
||||
await expect(clockTile(page)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const afterReload = await readClockStorage(page, user.id);
|
||||
expect(afterReload.position).toBe(String(draggedPosition));
|
||||
expect(afterReload.size).toBe("large");
|
||||
|
||||
// The visual large state must also be restored after reload.
|
||||
await expect(clockTile(page)).toHaveClass(/col-span-2/);
|
||||
await expect(clockTile(page)).toHaveClass(/row-span-2/);
|
||||
|
||||
// The clock's restored index in the combined grid must match the
|
||||
// persisted position — proves the saved value is actually re-applied to
|
||||
// the layout, not just sitting in localStorage.
|
||||
const clockIndexInGrid = await page.evaluate(() => {
|
||||
const clockIcon = document.querySelector('[aria-label="Clock"]');
|
||||
if (!clockIcon) return -1;
|
||||
const wrapper = clockIcon.parentElement;
|
||||
if (!wrapper || !wrapper.parentElement) return -1;
|
||||
const siblings = Array.from(wrapper.parentElement.children);
|
||||
return siblings.indexOf(wrapper);
|
||||
});
|
||||
expect(clockIndexInGrid).toBe(draggedPosition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
// UI test for the leave-group successor picker dialog in chat.tsx.
|
||||
//
|
||||
// Mirrors the backend coverage in
|
||||
// artifacts/api-server/tests/conversations-leave.test.mjs but at the UI layer:
|
||||
// it drives the actual dialog rendered by artifacts/tx-os/src/pages/chat.tsx
|
||||
// in a real browser, including:
|
||||
// - The cancel path (close the dialog -> nothing changes).
|
||||
// - The chosen-successor path (pick a specific member -> they become admin).
|
||||
//
|
||||
// Run with:
|
||||
// DATABASE_URL=... pnpm --filter @workspace/tx-os exec playwright test
|
||||
//
|
||||
// Browser binaries can be installed once with:
|
||||
// pnpm --filter @workspace/tx-os exec playwright install chromium
|
||||
//
|
||||
// The test seeds its own users + group via the database and cleans up after
|
||||
// itself, so it can run against the live dev environment without polluting it.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run the leave-group UI test");
|
||||
}
|
||||
|
||||
// Same convention as artifacts/api-server/tests/conversations-leave.test.mjs:
|
||||
// a precomputed bcrypt hash of the literal password "TestPass123!".
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdConversationIds = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username, displayName: prefix };
|
||||
}
|
||||
|
||||
async function createGroup(creatorId, members) {
|
||||
const groupName = `Successor Picker Test ${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO conversations (name_en, is_group, created_by)
|
||||
VALUES ($1, true, $2) RETURNING id`,
|
||||
[groupName, creatorId],
|
||||
);
|
||||
const conversationId = rows[0].id;
|
||||
createdConversationIds.push(conversationId);
|
||||
|
||||
// Insert participants in deterministic joined_at order so that the auto
|
||||
// promotion would pick the *first* non-admin member. The test then verifies
|
||||
// that an explicit successor choice can override that default.
|
||||
let offsetMs = 0;
|
||||
for (const m of members) {
|
||||
await pool.query(
|
||||
`INSERT INTO conversation_participants
|
||||
(conversation_id, user_id, is_admin, joined_at)
|
||||
VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`,
|
||||
[conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs],
|
||||
);
|
||||
offsetMs += 1000;
|
||||
}
|
||||
return { conversationId, groupName };
|
||||
}
|
||||
|
||||
async function getParticipants(conversationId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT user_id AS "userId", is_admin AS "isAdmin"
|
||||
FROM conversation_participants
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY joined_at ASC`,
|
||||
[conversationId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdConversationIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM messages WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversations WHERE id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function loginViaUi(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function openLeaveDialog(page, groupName) {
|
||||
await page.goto("/chat");
|
||||
// Wait for the conversation list to load and click the seeded group.
|
||||
await page.getByRole("button", { name: new RegExp(groupName) }).first().click();
|
||||
// Open the actions sheet, then "Leave group".
|
||||
await page.locator('[data-testid="button-chat-actions"]').click();
|
||||
await expect(page.locator('[data-testid="dialog-chat-actions"]')).toBeVisible();
|
||||
await page.locator('[data-testid="button-leave-group"]').click();
|
||||
await expect(page.locator('[data-testid="dialog-confirm-leave"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="successor-chooser"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Leave group: successor picker dialog", () => {
|
||||
test("cancel path leaves the group and admin assignments unchanged", async ({
|
||||
page,
|
||||
}) => {
|
||||
const admin = await createUser("leave_ui_cancel_admin");
|
||||
const m1 = await createUser("leave_ui_cancel_m1");
|
||||
const m2 = await createUser("leave_ui_cancel_m2");
|
||||
const { conversationId, groupName } = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
|
||||
await loginViaUi(page, admin.username, TEST_PASSWORD);
|
||||
await openLeaveDialog(page, groupName);
|
||||
|
||||
// Auto option is selected by default; both successor options are listed.
|
||||
await expect(
|
||||
page.locator('[data-testid="successor-option-auto"] input[type="radio"]'),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m1.id}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m2.id}"]`),
|
||||
).toBeVisible();
|
||||
|
||||
// Click the Cancel button inside the leave dialog.
|
||||
await page
|
||||
.locator('[data-testid="dialog-confirm-leave"] button')
|
||||
.filter({ hasText: /^(Cancel|إلغاء)$/ })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="dialog-confirm-leave"]'),
|
||||
).toBeHidden();
|
||||
|
||||
// Membership and admin flags must be unchanged after a cancel.
|
||||
const parts = await getParticipants(conversationId);
|
||||
expect(parts).toHaveLength(3);
|
||||
const adminRow = parts.find((p) => p.userId === admin.id);
|
||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
||||
expect(adminRow?.isAdmin).toBe(true);
|
||||
expect(m1Row?.isAdmin).toBe(false);
|
||||
expect(m2Row?.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
test("picking a specific successor promotes that member, not the auto pick", async ({
|
||||
page,
|
||||
}) => {
|
||||
const admin = await createUser("leave_ui_pick_admin");
|
||||
const m1 = await createUser("leave_ui_pick_m1"); // would be the auto pick
|
||||
const m2 = await createUser("leave_ui_pick_m2"); // explicitly chosen
|
||||
const { conversationId, groupName } = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
|
||||
await loginViaUi(page, admin.username, TEST_PASSWORD);
|
||||
await openLeaveDialog(page, groupName);
|
||||
|
||||
// Pick the later-joined member (m2) as the successor.
|
||||
await page
|
||||
.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`)
|
||||
.check();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.locator('[data-testid="successor-option-auto"] input[type="radio"]'),
|
||||
).not.toBeChecked();
|
||||
|
||||
// Confirm the leave.
|
||||
const leavePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/conversations/${conversationId}/leave`) &&
|
||||
resp.request().method() === "POST",
|
||||
);
|
||||
await page.locator('[data-testid="button-confirm-leave"]').click();
|
||||
const leaveResp = await leavePromise;
|
||||
expect(leaveResp.status()).toBe(200);
|
||||
|
||||
// The dialog closes and the user is returned to the conversation list.
|
||||
await expect(
|
||||
page.locator('[data-testid="dialog-confirm-leave"]'),
|
||||
).toBeHidden();
|
||||
|
||||
// The chosen successor — not the auto pick — must be the new admin.
|
||||
const parts = await getParticipants(conversationId);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts.find((p) => p.userId === admin.id)).toBeUndefined();
|
||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
||||
expect(m1Row?.isAdmin).toBe(false);
|
||||
expect(m2Row?.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user