mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-08 06:22:31 -05:00
parent
82c6a4b850
commit
3d4ac9f452
|
|
@ -7,6 +7,7 @@ import type { ActionFunction, ActionFunctionArgs } from "react-router";
|
|||
* The existing actions use:
|
||||
* - `successToast(message)` which returns `redirect("?__success=message")`
|
||||
* - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")`
|
||||
* - `{ fieldErrors }` returns for form validation failures
|
||||
*/
|
||||
export async function wrapActionForApi(
|
||||
actionFn: ActionFunction,
|
||||
|
|
@ -19,6 +20,19 @@ export async function wrapActionForApi(
|
|||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
if (response && typeof response === "object" && "fieldErrors" in response) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Validation failed",
|
||||
fieldErrors: response.fieldErrors,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return response as Response;
|
||||
} catch (e) {
|
||||
if (e instanceof Response && e.status === 302) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server";
|
||||
import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
tournamentTeamId: id.optional(),
|
||||
name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(),
|
||||
teamId: id.optional(),
|
||||
ownerUserId: id,
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: id,
|
||||
inGameName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(ADMIN_REGISTRATION_MAX_MEMBERS),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
const { id: tournamentId } = parseParams({
|
||||
params: args.params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
const body = await parseBody({
|
||||
request: args.request,
|
||||
schema: bodySchema,
|
||||
});
|
||||
|
||||
const existingTeam =
|
||||
typeof body.tournamentTeamId === "number"
|
||||
? (
|
||||
await TournamentRepository.findTeamsFullByTournamentId(tournamentId)
|
||||
).find((team) => team.id === body.tournamentTeamId)
|
||||
: undefined;
|
||||
if (typeof body.tournamentTeamId === "number" && !existingTeam) {
|
||||
return Response.json(
|
||||
{ error: "Invalid tournament team id" },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const linkedTeam = typeof body.teamId === "number";
|
||||
// the API can't upload logos, so an existing pickup logo is carried over as is
|
||||
const logo =
|
||||
!linkedTeam && existingTeam
|
||||
? existingImage(existingTeam.avatarImgId, existingTeam.pickupAvatarUrl)
|
||||
: null;
|
||||
|
||||
const internalRequest = new Request(args.request.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
_action: "UPSERT_REGISTRATION",
|
||||
tournamentTeamId: body.tournamentTeamId,
|
||||
linkedTeam,
|
||||
pickUpName: body.name ?? null,
|
||||
logo,
|
||||
teamId: body.teamId ?? null,
|
||||
ownerId: String(body.ownerUserId),
|
||||
members: body.members.map((member) => ({
|
||||
userId: member.userId,
|
||||
inGameName: member.inGameName ?? null,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
return wrapActionForApi(adminAction, {
|
||||
...args,
|
||||
params: { id: String(tournamentId) },
|
||||
request: internalRequest,
|
||||
});
|
||||
};
|
||||
|
|
@ -564,6 +564,25 @@ export interface TournamentStartingBracketsBody {
|
|||
}>;
|
||||
}
|
||||
|
||||
/** POST /api/tournament/{id}/teams/upsert */
|
||||
|
||||
/** @lintignore */
|
||||
export interface TournamentUpsertTeamBody {
|
||||
/** Present when editing an existing registration, absent when adding a new team. */
|
||||
tournamentTeamId?: number;
|
||||
/** Team name for a pickup team. Either `name` or `teamId` must be given. */
|
||||
name?: string;
|
||||
/** Linked sendou.ink team id. Name and logo are sourced from the team. */
|
||||
teamId?: number;
|
||||
/** Roster member that is the team owner/captain. */
|
||||
ownerUserId: number;
|
||||
/** Full roster; members missing from the list are removed from the team. */
|
||||
members: Array<{
|
||||
userId: number;
|
||||
inGameName?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/add-member */
|
||||
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/remove-member */
|
||||
|
||||
|
|
|
|||
|
|
@ -416,6 +416,10 @@ export default [
|
|||
"/tournament/:id/streams",
|
||||
"features/api-public/routes/tournament.$id.streams.ts",
|
||||
),
|
||||
route(
|
||||
"/tournament/:id/teams/upsert",
|
||||
"features/api-public/routes/tournament.$id.teams.upsert.ts",
|
||||
),
|
||||
route(
|
||||
"/tournament/:id/teams/:teamId/add-member",
|
||||
"features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { addHours } from "date-fns";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
|
|
@ -274,6 +275,87 @@ test.describe("Public API - Write endpoints", () => {
|
|||
expect(response.status()).toBe(200);
|
||||
});
|
||||
|
||||
test("upserts tournament team registration via API", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const { tournamentId, token } = await organizedTournament(factories);
|
||||
const roster = await factories.UserFactory.createMany(ROSTER_SIZE);
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
|
||||
const createResponse = await page.request.fetch(
|
||||
`/api/tournament/${tournamentId}/teams/upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorized(token),
|
||||
data: {
|
||||
name: "Api Pickup",
|
||||
ownerUserId: roster[0].id,
|
||||
members: roster.map((user) => ({ userId: user.id })),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(createResponse.status()).toBe(200);
|
||||
|
||||
const createdTeam = await teamByName(page, token, {
|
||||
tournamentId,
|
||||
name: "Api Pickup",
|
||||
});
|
||||
expect(createdTeam).toBeTruthy();
|
||||
expect(createdTeam.members).toHaveLength(ROSTER_SIZE);
|
||||
|
||||
const editResponse = await page.request.fetch(
|
||||
`/api/tournament/${tournamentId}/teams/upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorized(token),
|
||||
data: {
|
||||
tournamentTeamId: createdTeam.id,
|
||||
name: "Api Pickup Edited",
|
||||
ownerUserId: roster[0].id,
|
||||
members: roster
|
||||
.slice(0, ROSTER_SIZE - 1)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(editResponse.status()).toBe(200);
|
||||
|
||||
const editedTeam = await teamByName(page, token, {
|
||||
tournamentId,
|
||||
name: "Api Pickup Edited",
|
||||
});
|
||||
expect(editedTeam.id).toBe(createdTeam.id);
|
||||
expect(editedTeam.members).toHaveLength(ROSTER_SIZE - 1);
|
||||
});
|
||||
|
||||
test("returns 400 with field errors for invalid upsert registration body", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const { tournamentId, token } = await organizedTournament(factories);
|
||||
const owner = await factories.UserFactory.create();
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
|
||||
const response = await page.request.fetch(
|
||||
`/api/tournament/${tournamentId}/teams/upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorized(token),
|
||||
data: {
|
||||
ownerUserId: owner.id,
|
||||
members: [{ userId: owner.id }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.fieldErrors.pickUpName).toBeTruthy();
|
||||
});
|
||||
|
||||
test("updates member IGN via API", async ({ page, factories }) => {
|
||||
const { tournamentId, teamId, memberUserIds, token } =
|
||||
await organizedTournament(factories);
|
||||
|
|
@ -359,6 +441,21 @@ async function organizedTournament(
|
|||
};
|
||||
}
|
||||
|
||||
async function teamByName(
|
||||
page: Page,
|
||||
token: string,
|
||||
{ tournamentId, name }: { tournamentId: number; name: string },
|
||||
) {
|
||||
const response = await page.request.fetch(
|
||||
`/api/tournament/${tournamentId}/teams`,
|
||||
{ headers: authorized(token) },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const teams = await response.json();
|
||||
|
||||
return teams.find((team: { name: string }) => team.name === name);
|
||||
}
|
||||
|
||||
async function readToken(factories: Factories, userId: number) {
|
||||
await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user