From 3448f34e30f82802d53f1f28e077bbdbb85411d0 Mon Sep 17 00:00:00 2001 From: zk Date: Thu, 26 Mar 2026 00:34:40 +0000 Subject: [PATCH] feat: tests and touchups --- .gitignore | 5 +- Justfile-db | 6 +- ..._priv_svc_myt_card_info_user_id_unique.sql | 3 + .../server/src/actions/myt-card-info.test.ts | 160 ++++++++++++++++++ .../src/actions/update-user-settings.test.ts | 149 ++++++++++++++++ 5 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 db/migrations/20260326103000_priv_svc_myt_card_info_user_id_unique.sql create mode 100644 typescript/server/src/actions/myt-card-info.test.ts create mode 100644 typescript/server/src/actions/update-user-settings.test.ts diff --git a/.gitignore b/.gitignore index 9fe9504f0..9ae9b0011 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ I_HAVE_BOOTSTRAPPED_OK # new bootstrap flag BOOTSTRAP_OK # v3 bootstrap flag (omg) -_SETUP_OK \ No newline at end of file +_SETUP_OK + +# Local debugging: `just db-schema` (see Justfile-db) +db/schema.sql \ No newline at end of file diff --git a/Justfile-db b/Justfile-db index 86a8da015..4d4c0a9dd 100644 --- a/Justfile-db +++ b/Justfile-db @@ -42,4 +42,8 @@ server-db-test-template-reset: # Load the seeds into the database. db-load-seeds POSTGRES_DB=DEFAULT_DB: cd typescript/server && POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" \ - bun run src/scripts/load-seeds-pg.ts \ No newline at end of file + bun run src/scripts/load-seeds-pg.ts + +# Reset templatedb from migrations, then dump schema and indexes for local debugging. +db-schema: (db-reset "templatedb") + pg_dump "{{POSTGRES_URL}}/templatedb" --schema-only --no-owner --no-acl -f db/schema.sql \ No newline at end of file diff --git a/db/migrations/20260326103000_priv_svc_myt_card_info_user_id_unique.sql b/db/migrations/20260326103000_priv_svc_myt_card_info_user_id_unique.sql new file mode 100644 index 000000000..ecd3cee00 --- /dev/null +++ b/db/migrations/20260326103000_priv_svc_myt_card_info_user_id_unique.sql @@ -0,0 +1,3 @@ +-- One MYT card integration row per account (upsert target for UPDATE_MYT_CARD_INFO). +ALTER TABLE "priv_svc_myt_card_info" + ADD CONSTRAINT "priv_svc_myt_card_info_user_id_key" UNIQUE ("user_id"); diff --git a/typescript/server/src/actions/myt-card-info.test.ts b/typescript/server/src/actions/myt-card-info.test.ts new file mode 100644 index 000000000..ab1a0f044 --- /dev/null +++ b/typescript/server/src/actions/myt-card-info.test.ts @@ -0,0 +1,160 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_DeleteMytCardInfo } from "./delete-myt-card-info"; +import { ACTION_UpdateMytCardInfo } from "./update-myt-card-info"; + +const CODE_A = "00000000000000000001"; +const CODE_B = "00000000000000000002"; +const CODE_OTHER = "00000000000000009999"; + +async function getMytCardRow(userId: number) { + return DB.selectFrom("priv_svc_myt_card_info") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirst(); +} + +describe("ACTION_UpdateMytCardInfo", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + it("returns {} on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_A }); + + expect(result).toEqual({}); + }); + + it("inserts a row on first call", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_A }); + + const row = await getMytCardRow(userId); + expect(row).toMatchObject({ user_id: userId, card_access_code: CODE_A }); + }); + + it("updates card_access_code on second call for the same user", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_A }); + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_B }); + + const row = await getMytCardRow(userId); + expect(row?.card_access_code).toBe(CODE_B); + + const { count } = await DB.selectFrom("priv_svc_myt_card_info") + .select(DB.fn.countAll().as("count")) + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(Number(count)).toBe(1); + }); + + it("does not modify another user's row", async () => { + const other = await seedUser({ username: "other_user", email: "other@example.com" }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await DB.insertInto("priv_svc_myt_card_info") + .values({ user_id: other.id, card_access_code: CODE_OTHER }) + .execute(); + + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_A }); + + const otherRow = await getMytCardRow(other.id); + expect(otherRow?.card_access_code).toBe(CODE_OTHER); + }); + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateMytCardInfo(taker, { cardAccessCode: CODE_A }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "UPDATE_MYT_CARD_INFO") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "UPDATE_MYT_CARD_INFO", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +}); + +describe("ACTION_DeleteMytCardInfo", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + it("returns {} when no row exists", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteMytCardInfo(taker, {}); + + expect(result).toEqual({}); + }); + + it("deletes the user's row when present", async () => { + await DB.insertInto("priv_svc_myt_card_info") + .values({ user_id: userId, card_access_code: CODE_A }) + .execute(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteMytCardInfo(taker, {}); + + expect(await getMytCardRow(userId)).toBeUndefined(); + }); + + it("does not delete another user's row", async () => { + const other = await seedUser({ username: "other_user", email: "other@example.com" }); + await DB.insertInto("priv_svc_myt_card_info") + .values({ user_id: userId, card_access_code: CODE_A }) + .execute(); + await DB.insertInto("priv_svc_myt_card_info") + .values({ user_id: other.id, card_access_code: CODE_OTHER }) + .execute(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteMytCardInfo(taker, {}); + + const otherRow = await getMytCardRow(other.id); + expect(otherRow?.card_access_code).toBe(CODE_OTHER); + }); + + it("writes a GOOD action row on success when a row was deleted", async () => { + await DB.insertInto("priv_svc_myt_card_info") + .values({ user_id: userId, card_access_code: CODE_A }) + .execute(); + + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteMytCardInfo(taker, {}); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "DELETE_MYT_CARD_INFO") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "DELETE_MYT_CARD_INFO", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +}); diff --git a/typescript/server/src/actions/update-user-settings.test.ts b/typescript/server/src/actions/update-user-settings.test.ts new file mode 100644 index 000000000..b0ff6b366 --- /dev/null +++ b/typescript/server/src/actions/update-user-settings.test.ts @@ -0,0 +1,149 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_UpdateUserSettings } from "./update-user-settings"; + +async function getSettings(userId: number) { + return DB.selectFrom("account_settings") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); +} + +describe("ACTION_UpdateUserSettings", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ + username: "test_user", + withSettings: true, + })); + }); + + it("throws 400 when no fields are provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_UpdateUserSettings(taker, {})).rejects.toMatchObject({ code: 400 }); + }); + + it("writes a BAD action row when no fields are provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_UpdateUserSettings(taker, {})).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "UPDATE_USER_SETTINGS") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("returns {} on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateUserSettings(taker, { invisible: true }); + + expect(result).toEqual({}); + }); + + it("persists invisible to pf_invisible", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { invisible: true }); + + const row = await getSettings(userId); + expect(row.pf_invisible).toBe(true); + expect(row.pf_developer_mode).toBe(false); + expect(row.pf_advanced_mode).toBe(false); + expect(row.pf_contentious_content).toBe(false); + expect(row.pf_deletable_scores).toBe(false); + }); + + it("persists developerMode to pf_developer_mode", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { developerMode: true }); + + const row = await getSettings(userId); + expect(row.pf_developer_mode).toBe(true); + expect(row.pf_invisible).toBe(false); + }); + + it("persists contentiousContent to pf_contentious_content", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { contentiousContent: true }); + + const row = await getSettings(userId); + expect(row.pf_contentious_content).toBe(true); + }); + + it("persists advancedMode to pf_advanced_mode", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { advancedMode: true }); + + const row = await getSettings(userId); + expect(row.pf_advanced_mode).toBe(true); + }); + + it("persists deletableScores to pf_deletable_scores", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { deletableScores: true }); + + const row = await getSettings(userId); + expect(row.pf_deletable_scores).toBe(true); + }); + + it("updates multiple fields in one call and leaves others unchanged", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { + invisible: true, + developerMode: true, + }); + + const row = await getSettings(userId); + expect(row.pf_invisible).toBe(true); + expect(row.pf_developer_mode).toBe(true); + expect(row.pf_advanced_mode).toBe(false); + expect(row.pf_contentious_content).toBe(false); + expect(row.pf_deletable_scores).toBe(false); + }); + + it("does not modify another user's account_settings", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withSettings: true, + }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { invisible: true }); + + const otherRow = await getSettings(other.id); + expect(otherRow.pf_invisible).toBe(false); + }); + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUserSettings(taker, { invisible: true }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "UPDATE_USER_SETTINGS") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "UPDATE_USER_SETTINGS", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +});