Fix main team switch handling when leaving/deleting team + add secondary team tests
Some checks are pending
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2024-10-09 20:41:08 +03:00
parent 182d2fa3a7
commit 0932e8a5b5
10 changed files with 265 additions and 34 deletions

View File

@ -3,5 +3,6 @@ export const ADMIN_TEST_AVATAR = "6fc41a44b069a0d2152ac06d1e496c6c";
export const NZAP_TEST_DISCORD_ID = "455039198672453645";
export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://cdn.discordapp.com/avatars/455039198672453645/f809176af93132c3db5f0a5019e96339.webp?size=160
export const NZAP_TEST_ID = 2;
export const REGULAR_USER_TEST_ID = 2;
export const AMOUNT_OF_CALENDAR_EVENTS = 200;

View File

@ -1,8 +1,8 @@
import { sub } from "date-fns";
import { type NotNull, sql } from "kysely";
import { type NotNull, type Transaction, sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { DB, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { LFG } from "./lfg-constants";
@ -143,6 +143,9 @@ export function deletePost(id: number) {
return db.deleteFrom("LFGPost").where("id", "=", id).execute();
}
export function deletePostsByTeamId(teamId: number) {
return db.deleteFrom("LFGPost").where("teamId", "=", teamId).execute();
export function deletePostsByTeamId(teamId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.deleteFrom("LFGPost")
.where("teamId", "=", teamId)
.execute();
}

View File

@ -204,9 +204,10 @@ describe("Private user note sorting", () => {
});
const matchAction = wrappedAction<typeof matchSchema>({
action: rawMatchAction,
params: { id: "1" },
});
const matchActionParams = { id: "1" };
test("users with positive note sorted first", async () => {
await matchAction(
{
@ -215,7 +216,7 @@ describe("Private user note sorting", () => {
sentiment: "POSITIVE",
comment: "test",
},
{ user: "admin" },
{ user: "admin", params: matchActionParams },
);
const data = await lookingLoader({ user: "admin" });
@ -231,7 +232,7 @@ describe("Private user note sorting", () => {
sentiment: "NEGATIVE",
comment: "test",
},
{ user: "admin" },
{ user: "admin", params: matchActionParams },
);
const data = await lookingLoader({ user: "admin" });
@ -249,7 +250,7 @@ describe("Private user note sorting", () => {
sentiment: "POSITIVE",
comment: "test",
},
{ user: "admin" },
{ user: "admin", params: matchActionParams },
);
await matchAction(
{
@ -258,7 +259,7 @@ describe("Private user note sorting", () => {
sentiment: "NEGATIVE",
comment: "test",
},
{ user: "admin" },
{ user: "admin", params: matchActionParams },
);
const data = await lookingLoader({ user: "admin" });

View File

@ -4,6 +4,7 @@ import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
@ -107,6 +108,7 @@ export async function teamsByMemberUserId(
.select([
"TeamMemberWithSecondary.teamId as id",
"TeamMemberWithSecondary.isOwner",
"TeamMemberWithSecondary.isMainTeam",
])
.where("userId", "=", userId)
.execute();
@ -200,6 +202,52 @@ export function switchMainTeam({
});
}
export function del(teamId: number) {
return db.transaction().execute(async (trx) => {
const members = await trx
.selectFrom("TeamMember")
.select(["TeamMember.userId"])
.where("teamId", "=", teamId)
.execute();
// switch main team to another if they at least one secondary team
for (const member of members) {
const currentTeams = await teamsByMemberUserId(member.userId, trx);
const teamToSwitchTo = currentTeams.find((team) => team.id !== teamId);
if (!teamToSwitchTo) continue;
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 1,
})
.where("userId", "=", member.userId)
.where("teamId", "=", teamToSwitchTo.id)
.execute();
}
await trx
.updateTable("AllTeamMember")
.set({
isMainTeam: 0,
})
.where("AllTeamMember.teamId", "=", teamId)
.execute();
await LFGRepository.deletePostsByTeamId(teamId, trx);
await trx
.updateTable("AllTeam")
.set({
deletedAt: databaseTimestampNow(),
})
.where("id", "=", teamId)
.execute();
});
}
export function addNewTeamMember({
userId,
teamId,
@ -245,8 +293,9 @@ export function removeTeamMember({
invariant(teamToLeave, "User is not a member of this team");
invariant(!teamToLeave.isOwner, "Owner cannot leave the team");
const wasMainTeam = teamToLeave.isMainTeam;
const newMainTeam = currentTeams.find((team) => team.id !== teamId);
if (newMainTeam) {
if (wasMainTeam && newMainTeam) {
await trx
.updateTable("AllTeamMember")
.set({
@ -261,6 +310,7 @@ export function removeTeamMember({
.updateTable("AllTeamMember")
.set({
leftAt: databaseTimestampNow(),
isMainTeam: 0,
})
.where("userId", "=", userId)
.where("teamId", "=", teamId)

View File

@ -30,6 +30,7 @@ export const action: ActionFunction = async ({ request, params }) => {
teamId: team.id,
userId: user.id,
});
break;
}
case "MAKE_MAIN_TEAM": {
@ -37,6 +38,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: user.id,
teamId: team.id,
});
break;
}
default: {

View File

@ -1,11 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update "AllTeam"
set "deletedAt" = strftime('%s', 'now')
where "id" = @id
`);
export function deleteTeam(id: number) {
stm.run({ id });
}

View File

@ -18,7 +18,6 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { requireUserId } from "~/features/auth/core/user.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { isAdmin } from "~/permissions";
import {
type SendouRouteHandle,
@ -36,7 +35,6 @@ import {
uploadImagePage,
} from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
@ -89,8 +87,7 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "DELETE": {
await LFGRepository.deletePostsByTeamId(team.id);
deleteTeam(team.id);
await TeamRepository.del(team.id);
throw redirect(TEAM_SEARCH_PAGE);
}

View File

@ -0,0 +1,182 @@
import type { SerializeFrom } from "@remix-run/node";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db } from "~/db/sql";
import {
dbInsertUsers,
dbReset,
wrappedAction,
wrappedLoader,
} from "~/utils/Test";
import { loader as userProfileLoader } from "../../user-page/loaders/u.$identifier.index.server";
import * as TeamRepository from "../TeamRepository.server";
import { action as _teamPageAction } from "../actions/t.$customUrl.server";
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type {
createTeamSchema,
editTeamSchema,
teamProfilePageActionSchema,
} from "../team-schemas.server";
const loadUserTeamLoader = wrappedLoader<
SerializeFrom<typeof userProfileLoader>
>({
loader: userProfileLoader,
});
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
});
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
action: _teamPageAction,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
});
async function loadTeams() {
const data = await loadUserTeamLoader({
user: "regular",
params: {
identifier: String(REGULAR_USER_TEST_ID),
},
});
return { team: data.user.team, secondaryTeams: data.user.secondaryTeams };
}
describe("Secondary teams", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
it("first team created becomes main team", async () => {
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams).toHaveLength(0);
});
it("second team created becomes secondary", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
});
it("makes secondary team main team", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
});
it("sets main team (2 team)", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await teamPageAction(
{ _action: "MAKE_MAIN_TEAM" },
{ user: "regular", params: { customUrl: "team-2" } },
);
const { team } = await loadTeams();
expect(team!.name).toBe("Team 2");
});
it("when deleting the main team, the secondary team becomes main", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await editTeamAction(
{
_action: "DELETE",
},
{
user: "regular",
params: { customUrl: "team-1" },
},
);
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 2");
expect(secondaryTeams).toHaveLength(0);
});
it("when leaving the main team, the secondary team becomes main", async () => {
// has to be made by "admin" because can't leave team you own
await createTeamAction({ name: "Team 1" }, { user: "admin" });
await createTeamAction({ name: "Team 2" }, { user: "admin" });
await TeamRepository.addNewTeamMember({
userId: REGULAR_USER_TEST_ID,
teamId: 1,
maxTeamsAllowed: 2,
});
await TeamRepository.addNewTeamMember({
userId: REGULAR_USER_TEST_ID,
teamId: 2,
maxTeamsAllowed: 2,
});
const { team, secondaryTeams } = await loadTeams();
expect(team!.name).toBe("Team 1");
expect(secondaryTeams[0].name).toBe("Team 2");
await teamPageAction(
{
_action: "LEAVE_TEAM",
},
{
user: "regular",
params: { customUrl: "team-1" },
},
);
const { team: newTeam, secondaryTeams: newSecondaryTeams } =
await loadTeams();
expect(newTeam!.name).toBe("Team 2");
expect(newSecondaryTeams).toHaveLength(0);
});
it("creates max 2 teams as non-patron", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
expect(
createTeamAction({ name: "Team 3" }, { user: "regular" }),
).rejects.toThrow("status code: 400");
});
const makeUserPatron = () =>
db
.updateTable("User")
.set({ patronTier: 2 })
.where("id", "=", REGULAR_USER_TEST_ID)
.execute();
it("creates more than 2 teams as patron", async () => {
await makeUserPatron();
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await createTeamAction({ name: "Team 3" }, { user: "regular" });
const { secondaryTeams } = await loadTeams();
expect(secondaryTeams).toHaveLength(2);
});
});

View File

@ -1,7 +1,8 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import type { Params } from "@remix-run/react";
import type { z } from "zod";
import { ADMIN_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db, sql } from "~/db/sql";
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
import { authSessionStorage } from "~/features/auth/core/session.server";
@ -14,15 +15,16 @@ export function arrayContainsSameItems<T>(arr1: T[], arr2: T[]) {
export function wrappedAction<T extends z.ZodTypeAny>({
action,
params = {},
}: {
// TODO: strongly type this
action: (args: ActionFunctionArgs) => any;
params?: ActionFunctionArgs["params"];
}) {
return async (
args: z.infer<T>,
{ user }: { user?: "admin" | "regular" } = {},
{
user,
params = {},
}: { user?: "admin" | "regular"; params?: Params<string> } = {},
) => {
const body = new URLSearchParams(args);
const request = new Request("http://app.com/path", {
@ -58,7 +60,10 @@ export function wrappedLoader<T>({
// TODO: strongly type this
loader: (args: LoaderFunctionArgs) => any;
}) {
return async ({ user }: { user?: "admin" | "regular" } = {}) => {
return async ({
user,
params = {},
}: { user?: "admin" | "regular"; params?: Params<string> } = {}) => {
const request = new Request("http://app.com/path", {
method: "GET",
headers: await authHeader(user),
@ -67,7 +72,7 @@ export function wrappedLoader<T>({
try {
const data = await loader({
request,
params: {},
params,
context: {},
});
@ -87,7 +92,7 @@ async function authHeader(user?: "admin" | "regular"): Promise<HeadersInit> {
const session = await authSessionStorage.getSession();
session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : NZAP_TEST_ID);
session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID);
return [["Cookie", await authSessionStorage.commitSession(session)]];
}
@ -106,11 +111,12 @@ export const dbReset = () => {
sql.prepare("PRAGMA foreign_keys = ON").run();
};
export const dbInsertUsers = (count: number) =>
export const dbInsertUsers = (count?: number) =>
db
.insertInto("User")
.values(
Array.from({ length: count }).map((_, i) => ({
// defaults to 2 = admin & regular "NZAP"
Array.from({ length: count ?? 2 }).map((_, i) => ({
id: i + 1,
discordName: `user${i + 1}`,
discordId: String(i),

Binary file not shown.