diff --git a/README.md b/README.md index 47dc612..1957ce1 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ Open . The SQLite database is created at `data/preference ## How it works -- Existing login usernames are suggested as you type, before entering the shared password. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case. -- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to move a category to **No preference**, or Restore to rank it again. You can exclude every category. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately. -- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Excluded choices appear as blank cells and sort as the lowest preference; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by display name or login username, or refresh to load recent changes. When the names differ, the login username appears below the display name. -- Open **Account** to change your login username or display name independently. Display names can be shared; login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your display name to confirm. +- Similar existing login usernames are suggested as you type, before entering the shared password. The name already entered is never suggested. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case. +- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to leave a category unranked, or select **Rank** to add it to your ranking. You can leave every category unranked. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately. +- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Unranked choices appear as blank cells and sort after ranked choices; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by login username, or refresh to load recent changes. +- Open **Account** to change your login username, which is also the name shown to the group. Login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your login username to confirm. -This version uses a new database schema and does not migrate earlier preproduction data. Wipe the old database or Docker volume before starting it with existing data. +Databases from the version with separate display names are migrated automatically; existing login usernames become the names shown to the group. Databases using the earlier preproduction username-only schema still require a fresh database. The shared password does not verify who owns a username: anyone who knows it can sign in using another person's username. If this is exposed beyond a trusted group, serve it over HTTPS and set `COOKIE_SECURE=1`. diff --git a/server.py b/server.py index c2283d8..b8495ea 100644 --- a/server.py +++ b/server.py @@ -71,7 +71,6 @@ def user_payload(row: sqlite3.Row) -> dict: return { "userId": row["user_id"], "loginUsername": row["login_username"], - "displayName": row["display_name"], } @@ -87,9 +86,11 @@ def suggested_usernames(query: str, connection: sqlite3.Connection) -> list[str] matches = [] for row in connection.execute("SELECT login_key, login_username FROM users"): candidate = row["login_key"] + if candidate == key: + continue similarity = SequenceMatcher(None, key, candidate).ratio() - if candidate == key or candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64: - score = (candidate == key, candidate.startswith(key), similarity) + if candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64: + score = (candidate.startswith(key), similarity) matches.append((score, row["login_username"])) matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True) return [username for _, username in matches[:5]] @@ -123,7 +124,6 @@ def init_db() -> None: user_id INTEGER PRIMARY KEY, login_username TEXT NOT NULL, login_key TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( @@ -140,6 +140,8 @@ def init_db() -> None: ); """ ) + if "display_name" in {row["name"] for row in existing}: + connection.execute("ALTER TABLE users DROP COLUMN display_name") class Handler(BaseHTTPRequestHandler): @@ -188,7 +190,7 @@ class Handler(BaseHTTPRequestHandler): token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest() with db() as connection: row = connection.execute( - """SELECT u.user_id, u.login_username, u.display_name FROM sessions s + """SELECT u.user_id, u.login_username FROM sessions s JOIN users u ON u.user_id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?""", (token_hash, now().isoformat()), @@ -234,10 +236,10 @@ class Handler(BaseHTTPRequestHandler): (user_id,), ).fetchall() people_rows = connection.execute( - """SELECT u.user_id, u.login_username, u.display_name, + """SELECT u.user_id, u.login_username, p.property_id, p.preference_json, p.updated_at FROM users u LEFT JOIN preferences p ON p.user_id = u.user_id - ORDER BY u.display_name COLLATE NOCASE, u.login_username COLLATE NOCASE""" + ORDER BY u.login_username COLLATE NOCASE""" ).fetchall() own = { row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"]) @@ -314,11 +316,11 @@ class Handler(BaseHTTPRequestHandler): }) return connection.execute( - "INSERT OR IGNORE INTO users (login_username, login_key, display_name, created_at) VALUES (?, ?, ?, ?)", - (username, key, username, now().isoformat()), + "INSERT OR IGNORE INTO users (login_username, login_key, created_at) VALUES (?, ?, ?)", + (username, key, now().isoformat()), ) user = connection.execute( - "SELECT user_id, login_username, display_name FROM users WHERE login_key = ?", (key,) + "SELECT user_id, login_username FROM users WHERE login_key = ?", (key,) ).fetchone() connection.execute( "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", @@ -365,8 +367,8 @@ class Handler(BaseHTTPRequestHandler): body = self._body() if body is None: return - if body.get("confirmDisplayName") != session["display_name"]: - self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your display name exactly to confirm deletion."}) + if body.get("confirmLoginUsername") != session["login_username"]: + self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your login username exactly to confirm deletion."}) return with db() as connection: connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],)) @@ -387,24 +389,22 @@ class Handler(BaseHTTPRequestHandler): if body is None: return login_username = body.get("loginUsername") - display_name = body.get("displayName") - if not isinstance(login_username, str) or not isinstance(display_name, str): - self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username and display name."}) + if not isinstance(login_username, str): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username."}) return login_username = login_username.strip() - display_name = display_name.strip() - if not valid_account_name(login_username) or not valid_account_name(display_name): - self._json(HTTPStatus.BAD_REQUEST, {"error": "Each name must be 1–32 printable characters."}) + if not valid_account_name(login_username): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be 1–32 printable characters."}) return try: with db() as connection: connection.execute( - """UPDATE users SET login_username = ?, login_key = ?, display_name = ? + """UPDATE users SET login_username = ?, login_key = ? WHERE user_id = ?""", - (login_username, login_key_for(login_username), display_name, session["user_id"]), + (login_username, login_key_for(login_username), session["user_id"]), ) user = connection.execute( - "SELECT user_id, login_username, display_name FROM users WHERE user_id = ?", + "SELECT user_id, login_username FROM users WHERE user_id = ?", (session["user_id"],), ).fetchone() except sqlite3.IntegrityError: @@ -442,7 +442,7 @@ class Handler(BaseHTTPRequestHandler): or len(value) != len(set(value)) or not set(value).issubset(categories) ): - self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave excluded categories out."}) + self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave other categories unranked."}) return updated_at = now().isoformat() with db() as connection: diff --git a/static/app.js b/static/app.js index 426b65e..257bc92 100644 --- a/static/app.js +++ b/static/app.js @@ -3,7 +3,7 @@ const state = { view: "mine", propertyId: "class", drafts: {}, - sort: { key: "displayName", direction: 1 }, + sort: { key: "loginUsername", direction: 1 }, groupMode: "rank", search: "", busy: false, @@ -62,7 +62,7 @@ function propertyTabs() { } function shell(content) { - const username = state.data.user.displayName; + const username = state.data.user.loginUsername; app.innerHTML = `
Bravo TeamForever Interest
@@ -70,11 +70,12 @@ function shell(content) {

Share your preferences

Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.

-
@@ -88,7 +89,7 @@ function shell(content) { })); app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => { state.propertyId = button.dataset.property; - state.sort = { key: "displayName", direction: 1 }; + state.sort = { key: "loginUsername", direction: 1 }; state.notice = ""; render(); })); @@ -211,16 +212,14 @@ function renderEditor() { - +
`).join(""); - const excludedCards = excluded.map((category) => `
  • ${escapeHtml(category)}NO PREFERENCE
  • `).join(""); + const excludedCards = excluded.map((category) => `
  • ${escapeHtml(category)}NO PREFERENCE
  • `).join(""); shell(`
    -
    01 / YOUR RANKING${status}
    -

    Rank your ${escapeHtml(item.name.toLowerCase())} choices

    Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.

    ${draft.length} RANKED
    - ${propertyTabs()} -
    MOST PREFERREDDRAG TO ORDER
      ${cards}
    ${draft.length ? `
    LOWEST RANKED
    ` : `

    No ranked choices. Restore a category below to rank it.

    `} -
    No preference${excluded.length} EXCLUDED

    Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.

    ${excluded.length ? `
      ${excludedCards}
    ` : `

    No categories excluded.

    `}
    +
    ${status}
    +
    HIGHEST RANKEDDRAG TO ORDER
      ${cards}
    ${draft.length ? `
    LOWEST RANKED
    ` : `

    No ranked choices. Rank a category below to add it.

    `} +
    No preference${excluded.length} UNRANKED

    Unranked choices have no preference. They appear blank in group tables and sort after ranked choices.

    ${excluded.length ? `
      ${excludedCards}
    ` : `

    All categories are ranked.

    `}
    `); @@ -261,9 +260,7 @@ function renderContentEditor() { `).join(""); shell(`
    -
    01 / YOUR CONTENT${status}
    -

    Choose your content interests

    Select Yes, Maybe, or No for each activity.

    ${answered} / ${item.categories.length} ANSWERED
    - ${propertyTabs()} +
    ${status}
    ${rows}
    `); @@ -358,17 +355,15 @@ function formatDate(value) { } function renderAccount() { - const { loginUsername, displayName } = state.data.user; + const { loginUsername } = state.data.user; shell(`
    -
    03 / YOUR ACCOUNT
    -

    Account settings

    Change the name you sign in with or the name shown to the group.

    +

    Account settings

    Change the name you sign in with and show to the group.

    -
    DELETE ACCOUNT

    Remove yourself from the roster

    This permanently deletes your account, all saved preferences, and all active sessions. Type ${escapeHtml(displayName)} exactly to confirm.

    -
    +
    DELETE ACCOUNT

    Remove yourself from the roster

    This permanently deletes your account, all saved preferences, and all active sessions. Type ${escapeHtml(loginUsername)} exactly to confirm.

    +
    `); const accountForm = app.querySelector("#account-form"); @@ -382,7 +377,6 @@ function renderAccount() { method: "PUT", body: JSON.stringify({ loginUsername: accountForm.elements.namedItem("loginUsername").value, - displayName: accountForm.elements.namedItem("displayName").value, }), }); await load(true); @@ -394,18 +388,18 @@ function renderAccount() { } }); const form = app.querySelector("#delete-form"); - const input = form.querySelector("#confirm-display-name"); + const input = form.querySelector("#confirm-login-username"); const button = form.querySelector('button[type="submit"]'); input.addEventListener("input", () => { - button.disabled = input.value !== displayName; + button.disabled = input.value !== loginUsername; form.querySelector("#delete-error").textContent = ""; }); form.addEventListener("submit", async (event) => { event.preventDefault(); - if (input.value !== displayName) return; + if (input.value !== loginUsername) return; button.disabled = true; try { - await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) }); + await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmLoginUsername: input.value }) }); state.data = null; state.drafts = {}; state.view = "mine"; @@ -427,7 +421,7 @@ function sortPeople(people, propertyId) { const { key, direction } = state.sort; return [...people].sort((a, b) => { const value = (person) => { - if (key === "displayName") return person.displayName; + if (key === "loginUsername") return person.loginUsername; const preference = person.preferences[propertyId]; if (key === "updated") return preference?.updatedAt; if (key.startsWith("category:")) { @@ -466,7 +460,7 @@ function renderGroup() { const isContent = item.id === "content"; const query = state.search.trim().toLocaleLowerCase(); const people = sortPeople(state.data.people.filter((person) => - person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query) + person.loginUsername.toLocaleLowerCase().includes(query) ), item.id); const submitted = state.data.people.filter((person) => person.preferences[item.id]).length; const columns = !isContent && state.groupMode === "rank" @@ -487,19 +481,17 @@ function renderGroup() { const value = column.category ? ordinal(rank) : preference.ranking[column.index]; return `${escapeHtml(value)}`; }).join(""); - return `
    ${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `YOU` : ""}${person.loginUsername !== person.displayName ? `` : ""}
    ${ranks}${preference ? formatDate(preference.updatedAt) : `Pending`}`; + return `
    ${escapeHtml(person.loginUsername)}${person.userId === state.data.user.userId ? `YOU` : ""}
    ${ranks}${preference ? formatDate(preference.updatedAt) : `Pending`}`; }).join(""); shell(`
    -
    02 / THE GROUP${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}
    -

    Everyone’s preferences

    Explore the group’s choices. Select any column heading to sort.

    - ${propertyTabs()} +
    ${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}
    ${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}${people.length} ${people.length === 1 ? "person" : "people"} shown
    ${isContent ? "" : `
    `}
    -
    ${headers}${rows || ``}
    ${sortHeader("displayName", "PERSON")}${sortHeader("updated", "UPDATED")}
    No people match that search.
    - +
    ${headers}${rows || ``}
    ${sortHeader("loginUsername", "PERSON")}${sortHeader("updated", "UPDATED")}
    No people match that search.
    +
    `); app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => { state.groupMode = button.dataset.groupMode; - state.sort = { key: "displayName", direction: 1 }; + state.sort = { key: "loginUsername", direction: 1 }; render(); })); app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => { diff --git a/static/styles.css b/static/styles.css index 34c92a9..5d8b117 100644 --- a/static/styles.css +++ b/static/styles.css @@ -39,7 +39,8 @@ h1, h2, h3, p { margin-top: 0; } .circle-icon { display: grid; place-items: center; overflow: hidden; border: 1px solid #8c744d; border-radius: 50%; background: #e8e5dd; box-shadow: 0 0 0 4px #b9954517; } .circle-icon img { display: block; width: 100%; height: 100%; object-fit: cover; } .heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; } -.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; margin-bottom: 24px; } +.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; } +.main-nav.account-nav { margin-bottom: 24px; } .nav-item { border: 0; border-bottom: 2px solid transparent; background: none; padding: 17px 22px 18px; margin-bottom: -1px; color: #a4afb9; font-weight: 600; font-size: 19px; } .nav-item.active { color: #f4e3bd; border-color: #d6aa59; } .nav-item:hover { color: #fff; } @@ -48,7 +49,6 @@ h1, h2, h3, p { margin-top: 0; } .notice { border: 1px solid #856c3a; background: #2a2a25; color: #f2d598; padding: 12px 16px; border-radius: 5px; font-size: 19px; margin-bottom: 16px; } .content-card { background: #142231; border: 1px solid #31404d; border-radius: 7px; box-shadow: 0 20px 75px rgba(0,0,0,.16); } .card-topline { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2c3b48; min-height: 53px; padding: 0 31px; } -.section-kicker { color: #c9a866; font-size: 15px; letter-spacing: .2em; font-weight: 700; } .status, .group-total { color: #9eabb5; font-size: 16px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; } .status { display: flex; align-items: center; gap: 8px; } .status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; } @@ -57,8 +57,7 @@ h1, h2, h3, p { margin-top: 0; } .section-heading { padding: 30px 31px 20px; display: flex; justify-content: space-between; align-items: center; gap: 15px; } .section-heading h2 { font: 600 25px/1.3 'Cinzel', Georgia, serif; color: #f1ebdd; letter-spacing: -.015em; margin-bottom: 7px; } .section-heading p { color: #9facb7; font-size: 19px; line-height: 1.55; margin: 0; } -.count-badge { white-space: nowrap; border: 1px solid #46505a; color: #bfaa7d; padding: 8px 11px; font-size: 15px; font-weight: 700; letter-spacing: .12em; } -.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; } +.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; margin: 12px 0; } .property-tab { padding: 13px 8px 14px; border: 0; border-bottom: 2px solid transparent; background: none; color: #9eacb6; font-size: 19px; font-weight: 700; margin-bottom: -1px; white-space: nowrap; } .property-tab.selected { color: #f0d6a0; border-color: #d8ac61; } .property-tab:hover { color: #fff; } @@ -139,7 +138,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; } .sort-button span { font-size: 14px; color: #bc9d65; } .person-cell { display: grid; gap: 3px; } .person-name { color: #e8e9e7; } -.person-login { color: #91a2af; font-size: 15px; font-weight: 400; } .person-cell small { display: inline-block; font-size: 12px; letter-spacing: .08em; color: #d8b879; margin-left: 7px; } .table-choice { color: #cbd3d7; } .table-choice.first { color: #f0cf8d; font-weight: 700; } @@ -154,7 +152,7 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; } .table-footer { padding: 17px 31px; color: #8d9da9; font-size: 16px; } .account-details { margin: 0 31px 28px; padding: 22px 20px; background: #1b2c3a; border: 1px solid #394957; display: grid; gap: 20px; } -.account-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.account-fields { max-width: 420px; } .account-details label, .danger-kicker { display: block; font-size: 15px; letter-spacing: .16em; font-weight: 700; color: #c9a866; margin-bottom: 8px; } .account-details input { width: 100%; height: 50px; border: 1px solid #52606a; background: #122230; color: #f2f0e8; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; } .account-details input:focus { border-color: #d9b674; } @@ -223,7 +221,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; } .property-tabs { padding: 0 17px; } .section-heading h2 { font-size: 19px; } .section-heading p { font-size: 16px; } - .count-badge { display: none; } .card-footer { flex-direction: column; align-items: stretch; } .card-footer .primary-button { align-self: stretch; } .table-tools { align-items: stretch; flex-direction: column; } @@ -242,7 +239,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; } .search-box { flex: 1; } .search-box input { width: 100%; } .account-details { margin-left: 17px; margin-right: 17px; } - .account-fields { grid-template-columns: 1fr; } .account-save { flex-direction: column; align-items: stretch; } .danger-zone { padding-left: 17px; padding-right: 17px; } .login-card { padding: 29px 25px; } diff --git a/tests/test_server.py b/tests/test_server.py index 902557e..e182584 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -53,7 +53,7 @@ class ServerTest(unittest.TestCase): table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")] for table in ("sessions", "preferences") } - self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "display_name", "created_at"]) + self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "created_at"]) self.assertEqual(columns["sessions"], ["token_hash", "user_id", "expires_at"]) self.assertEqual(columns["preferences"], ["user_id", "property_id", "preference_json", "updated_at"]) self.assertEqual(foreign_keys["sessions"], [("user_id", "user_id")]) @@ -75,6 +75,39 @@ class ServerTest(unittest.TestCase): server.DB_PATH = current_db_path connection.close() + def test_separate_display_names_migrate_without_losing_accounts(self): + old_path = Path(self.tempdir.name) / "separate-names.sqlite3" + current_db_path = server.DB_PATH + connection = sqlite3.connect(old_path) + try: + connection.execute("""CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, login_username TEXT NOT NULL, + login_key TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + created_at TEXT NOT NULL)""") + connection.execute("""CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + expires_at TEXT NOT NULL)""") + connection.execute("""CREATE TABLE preferences ( + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + property_id TEXT NOT NULL, preference_json TEXT NOT NULL, + updated_at TEXT NOT NULL, PRIMARY KEY (user_id, property_id))""") + connection.execute("INSERT INTO users VALUES (1, 'Aly', 'aly', 'Other Name', 'today')") + connection.execute("INSERT INTO sessions VALUES ('token', 1, 'later')") + connection.execute("INSERT INTO preferences VALUES (1, 'role', '[\"Tank\"]', 'today')") + connection.commit() + server.DB_PATH = old_path + server.init_db() + self.assertEqual( + [row[1] for row in connection.execute("PRAGMA table_info(users)")], + ["user_id", "login_username", "login_key", "created_at"], + ) + self.assertEqual(connection.execute("SELECT login_username FROM users").fetchone()[0], "Aly") + self.assertEqual(connection.execute("SELECT user_id FROM sessions").fetchone()[0], 1) + self.assertEqual(connection.execute("SELECT preference_json FROM preferences").fetchone()[0], '["Tank"]') + finally: + server.DB_PATH = current_db_path + connection.close() + def test_login_save_and_group_view(self): status, data, _ = self.request("GET", "/api/bootstrap") self.assertEqual(status, 200) @@ -87,7 +120,7 @@ class ServerTest(unittest.TestCase): status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"}) self.assertEqual(status, 200) self.assertEqual(data["user"]["loginUsername"], "Aly") - self.assertEqual(data["user"]["displayName"], "Aly") + self.assertNotIn("displayName", data["user"]) aly_id = data["user"]["userId"] cookie = cookie_header.split(";", 1)[0] @@ -130,22 +163,23 @@ class ServerTest(unittest.TestCase): status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"}) self.assertEqual(status, 200) second_cookie = second_cookie_header.split(";", 1)[0] - status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly", "displayName": "Bex"}, cookie) + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly"}, cookie) self.assertEqual(status, 200) self.assertEqual(data["user"]["userId"], aly_id) status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) self.assertEqual(len(data["people"]), 2) - self.assertEqual([person["displayName"] for person in data["people"]], ["Bex", "Bex"]) + self.assertEqual([person["loginUsername"] for person in data["people"]], ["Aly", "Bex"]) + self.assertTrue(all("displayName" not in person for person in data["people"])) self.assertEqual(len({person["userId"] for person in data["people"]}), 2) - status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX", "displayName": "Bex"}, cookie) + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX"}, cookie) self.assertEqual(status, 409) - status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew", "displayName": "Bex"}, cookie) + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew"}, cookie) self.assertEqual(status, 200) self.assertEqual(data["user"]["userId"], aly_id) status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie) self.assertEqual(data["user"]["loginUsername"], "AlyNew") - self.assertEqual(data["user"]["displayName"], "Bex") + self.assertNotIn("displayName", data["user"]) self.assertEqual(data["preferences"]["role"]["ranking"], ranking) self.assertEqual(data["preferences"]["content"]["choices"], content_choices) status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"}) @@ -155,6 +189,11 @@ class ServerTest(unittest.TestCase): self.assertEqual(status, 200) self.assertIn("AlyNew", data["suggestions"]) + for typed_name in ("AlyNew", "alynew"): + status, data, _ = self.request("POST", "/api/username-suggestions", {"loginUsername": typed_name}) + self.assertEqual(status, 200) + self.assertNotIn("AlyNew", data["suggestions"]) + status, _, _ = self.request("POST", "/api/logout", {}, cookie) self.assertEqual(status, 204) status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) @@ -224,9 +263,9 @@ class ServerTest(unittest.TestCase): "PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie ) self.assertEqual(status, 200) - status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "meridian"}, cookie) + status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "meridian"}, cookie) self.assertEqual(status, 400) - status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "Meridian"}, cookie) + status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "Meridian"}, cookie) self.assertEqual(status, 204) status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie) diff --git a/tests/test_table.js b/tests/test_table.js index 9af74f6..5138e5c 100644 --- a/tests/test_table.js +++ b/tests/test_table.js @@ -13,24 +13,24 @@ test("category columns sort excluded choices as lowest and pending submissions l }); vm.runInContext(source, context); context.people = [ - { userId: 1, loginUsername: "Third", displayName: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } }, - { userId: 2, loginUsername: "First", displayName: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } }, - { userId: 3, loginUsername: "Pending", displayName: "Pending", preferences: {} }, - { userId: 4, loginUsername: "Second", displayName: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } }, - { userId: 5, loginUsername: "Excluded", displayName: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } }, + { userId: 1, loginUsername: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } }, + { userId: 2, loginUsername: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } }, + { userId: 3, loginUsername: "Pending", preferences: {} }, + { userId: 4, loginUsername: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } }, + { userId: 5, loginUsername: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } }, ]; const names = vm.runInContext( - "state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", + "state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)", context, ); assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]); const reversed = vm.runInContext( - "state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.displayName)", + "state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.loginUsername)", context, ); assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]); const rankColumn = vm.runInContext( - "state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", + "state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)", context, ); assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]); @@ -51,16 +51,18 @@ test("group tables leave excluded categories and empty rank positions blank", () vm.runInContext(source, context); vm.runInContext(` state.data = { - user: { userId: 1, loginUsername: "Aly", displayName: "Aly" }, + user: { userId: 1, loginUsername: "Aly" }, properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }], preferences: { class: { ranking: ["Mage"] } }, - people: [{ userId: 1, loginUsername: "Aly", displayName: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }], + people: [{ userId: 1, loginUsername: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }], }; state.groupMode = "category"; renderGroup(); `, context); assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2); assert.doesNotMatch(app.innerHTML, />0thAlyYOU<\/small>/); + assert.doesNotMatch(app.innerHTML, /person-login|undefined/); vm.runInContext('state.groupMode = "rank"; renderGroup();', context); assert.equal((app.innerHTML.match(/aria-label="No ranked choice"><\/td>/g) || []).length, 2); }); @@ -72,13 +74,13 @@ test("content columns sort Yes, Maybe, No and leave unanswered people last", () }); vm.runInContext(source, context); context.people = [ - { userId: 1, loginUsername: "Noah", displayName: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } }, - { userId: 2, loginUsername: "Yara", displayName: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } }, - { userId: 3, loginUsername: "Missing", displayName: "Missing", preferences: {} }, - { userId: 4, loginUsername: "Mira", displayName: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } }, + { userId: 1, loginUsername: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } }, + { userId: 2, loginUsername: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } }, + { userId: 3, loginUsername: "Missing", preferences: {} }, + { userId: 4, loginUsername: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } }, ]; const names = vm.runInContext( - "state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.displayName)", + "state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.loginUsername)", context, ); assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]);