generated from nhcarrigan/template
Compare commits
3 Commits
v0.3.2
..
8ccc3f4d0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ccc3f4d0d
|
|||
|
2c34fe2c81
|
|||
|
5025948530
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.3.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id"
|
||||||
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret"
|
||||||
|
DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri"
|
||||||
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret"
|
||||||
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url"
|
||||||
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret"
|
||||||
@@ -6,4 +8,6 @@ PORT="op://Environment Variables - Naomi/Elysium/port"
|
|||||||
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin"
|
||||||
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook"
|
||||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token"
|
||||||
|
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id"
|
||||||
|
DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id"
|
||||||
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
@@ -7,9 +7,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordClientId = "1479551654264049908";
|
|
||||||
const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback";
|
|
||||||
|
|
||||||
interface DiscordTokenResponse {
|
interface DiscordTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
@@ -34,18 +31,24 @@ interface DiscordUser {
|
|||||||
const exchangeCode = async(
|
const exchangeCode = async(
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<DiscordTokenResponse> => {
|
): Promise<DiscordTokenResponse> => {
|
||||||
|
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||||
|
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||||
|
|
||||||
if (clientSecret === undefined || clientSecret === "") {
|
if (
|
||||||
|
clientId === undefined || clientId === ""
|
||||||
|
|| clientSecret === undefined || clientSecret === ""
|
||||||
|
|| redirectUri === undefined || redirectUri === ""
|
||||||
|
) {
|
||||||
throw new Error("Discord OAuth environment variables are required");
|
throw new Error("Discord OAuth environment variables are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
client_id: discordClientId,
|
client_id: clientId,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
code: code,
|
code: code,
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: discordRedirectUri,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -143,9 +146,19 @@ const fetchDiscordUserById = async(
|
|||||||
* @throws {Error} If OAuth environment variables are missing.
|
* @throws {Error} If OAuth environment variables are missing.
|
||||||
*/
|
*/
|
||||||
const buildOAuthUrl = (): string => {
|
const buildOAuthUrl = (): string => {
|
||||||
|
const clientId = process.env.DISCORD_CLIENT_ID;
|
||||||
|
const redirectUri = process.env.DISCORD_REDIRECT_URI;
|
||||||
|
|
||||||
|
if (
|
||||||
|
clientId === undefined || clientId === ""
|
||||||
|
|| redirectUri === undefined || redirectUri === ""
|
||||||
|
) {
|
||||||
|
throw new Error("Discord OAuth environment variables are required");
|
||||||
|
}
|
||||||
|
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
client_id: discordClientId,
|
client_id: clientId,
|
||||||
redirect_uri: discordRedirectUri,
|
redirect_uri: redirectUri,
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "identify",
|
scope: "identify",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
const discordGuildId = "1354624415861833870";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discord Gateway opcodes used by this client.
|
* Discord Gateway opcodes used by this client.
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +36,8 @@ const handleGuildMemberAdd = async(
|
|||||||
discordId: string,
|
discordId: string,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (guildId !== discordGuildId) {
|
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
||||||
|
if (guildId !== configuredGuildId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +66,8 @@ const handleGuildMemberRemove = async(
|
|||||||
discordId: string,
|
discordId: string,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (guildId !== discordGuildId) {
|
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
||||||
|
if (guildId !== configuredGuildId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ const suppressNotifications = 4096;
|
|||||||
/**
|
/**
|
||||||
* The Discord role ID for the Elysian role granted to all Elysium players.
|
* The Discord role ID for the Elysian role granted to all Elysium players.
|
||||||
*/
|
*/
|
||||||
const discordGuildId = "1354624415861833870";
|
|
||||||
const elysianRoleId = "1486144823684628490";
|
const elysianRoleId = "1486144823684628490";
|
||||||
const apotheosisRoleId = "1479966598210129991";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
|
* Grants the Elysian Discord role to the given player and returns whether they are in the guild.
|
||||||
@@ -30,14 +28,18 @@ const apotheosisRoleId = "1479966598210129991";
|
|||||||
*/
|
*/
|
||||||
const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID;
|
||||||
|
|
||||||
if (botToken === undefined || botToken === "") {
|
if (
|
||||||
|
botToken === undefined || botToken === ""
|
||||||
|
|| guildId === undefined || guildId === ""
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`,
|
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${elysianRoleId}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bot ${botToken}`,
|
"Authorization": `Bot ${botToken}`,
|
||||||
@@ -66,14 +68,20 @@ const grantElysianRole = async(discordId: string): Promise<boolean> => {
|
|||||||
*/
|
*/
|
||||||
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
const grantApotheosisRole = async(discordId: string): Promise<void> => {
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID;
|
||||||
|
const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID;
|
||||||
|
|
||||||
if (botToken === undefined || botToken === "") {
|
if (
|
||||||
|
botToken === undefined || botToken === ""
|
||||||
|
|| guildId === undefined || guildId === ""
|
||||||
|
|| roleId === undefined || roleId === ""
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(
|
await fetch(
|
||||||
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
|
`${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bot ${botToken}`,
|
"Authorization": `Bot ${botToken}`,
|
||||||
|
|||||||
@@ -18,31 +18,51 @@ describe("discord service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildOAuthUrl", () => {
|
describe("buildOAuthUrl", () => {
|
||||||
|
it("throws when DISCORD_CLIENT_ID is missing", async () => {
|
||||||
|
delete process.env["DISCORD_CLIENT_ID"];
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||||
|
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||||
|
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when DISCORD_REDIRECT_URI is missing", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||||
|
delete process.env["DISCORD_REDIRECT_URI"];
|
||||||
|
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||||
|
expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a URL with correct query params", async () => {
|
it("returns a URL with correct query params", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "client123";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback";
|
||||||
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
const { buildOAuthUrl } = await import("../../src/services/discord.js");
|
||||||
const url = buildOAuthUrl();
|
const url = buildOAuthUrl();
|
||||||
expect(url).toContain("client_id=1479551654264049908");
|
expect(url).toContain("client_id=client123");
|
||||||
expect(url).toContain("response_type=code");
|
expect(url).toContain("response_type=code");
|
||||||
expect(url).toContain("scope=identify");
|
expect(url).toContain("scope=identify");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exchangeCode", () => {
|
describe("exchangeCode", () => {
|
||||||
it("throws when DISCORD_CLIENT_SECRET is missing", async () => {
|
it("throws when env vars are missing", async () => {
|
||||||
delete process.env["DISCORD_CLIENT_SECRET"];
|
delete process.env["DISCORD_CLIENT_ID"];
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when response is not ok", async () => {
|
it("throws when response is not ok", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns parsed body on success", async () => {
|
it("returns parsed body on success", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" };
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) });
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
@@ -76,7 +96,9 @@ describe("discord service", () => {
|
|||||||
|
|
||||||
describe("exchangeCode non-Error throw", () => {
|
describe("exchangeCode non-Error throw", () => {
|
||||||
it("re-throws when fetch rejects with a non-Error value", async () => {
|
it("re-throws when fetch rejects with a non-Error value", async () => {
|
||||||
|
process.env["DISCORD_CLIENT_ID"] = "cid";
|
||||||
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
process.env["DISCORD_CLIENT_SECRET"] = "secret";
|
||||||
|
process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb";
|
||||||
mockFetch.mockRejectedValueOnce("raw string error");
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
const { exchangeCode } = await import("../../src/services/discord.js");
|
const { exchangeCode } = await import("../../src/services/discord.js");
|
||||||
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
await expect(exchangeCode("some_code")).rejects.toBe("raw string error");
|
||||||
|
|||||||
@@ -16,48 +16,60 @@ vi.mock("../../src/services/logger.js", () => ({
|
|||||||
|
|
||||||
import { prisma } from "../../src/db/client.js";
|
import { prisma } from "../../src/db/client.js";
|
||||||
|
|
||||||
const discordGuildId = "1354624415861833870";
|
|
||||||
|
|
||||||
describe("gateway service", () => {
|
describe("gateway service", () => {
|
||||||
|
const ORIGINAL_ENV = process.env;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
process.env = ORIGINAL_ENV;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleGuildMemberAdd", () => {
|
describe("handleGuildMemberAdd", () => {
|
||||||
it("sets inGuild to true for the matching guild", async () => {
|
it("sets inGuild to true for the matching guild", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
await handleGuildMemberAdd("user123", discordGuildId);
|
await handleGuildMemberAdd("user123", "guild123");
|
||||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||||
data: { inGuild: true },
|
data: { inGuild: true },
|
||||||
where: { discordId: "user123" },
|
where: { discordId: "user123" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-ops when guild id does not match the configured guild", async () => {
|
it("no-ops when guild id does not match", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
await handleGuildMemberAdd("user123", "other_guild");
|
await handleGuildMemberAdd("user123", "other_guild");
|
||||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("no-ops when DISCORD_GUILD_ID env var is missing and guild does not match undefined", async () => {
|
||||||
|
delete process.env["DISCORD_GUILD_ID"];
|
||||||
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberAdd("user123", "guild123");
|
||||||
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("logs error when prisma throws an Error", async () => {
|
it("logs error when prisma throws an Error", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
const dbError = new Error("DB failure");
|
const dbError = new Error("DB failure");
|
||||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
const { logger } = await import("../../src/services/logger.js");
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
await handleGuildMemberAdd("user123", discordGuildId);
|
await handleGuildMemberAdd("user123", "guild123");
|
||||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
|
expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs error when prisma throws a non-Error", async () => {
|
it("logs error when prisma throws a non-Error", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||||
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberAdd } = await import("../../src/services/gateway.js");
|
||||||
const { logger } = await import("../../src/services/logger.js");
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
await handleGuildMemberAdd("user123", discordGuildId);
|
await handleGuildMemberAdd("user123", "guild123");
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
"gateway_member_add",
|
"gateway_member_add",
|
||||||
new Error("raw error"),
|
new Error("raw error"),
|
||||||
@@ -67,35 +79,46 @@ describe("gateway service", () => {
|
|||||||
|
|
||||||
describe("handleGuildMemberRemove", () => {
|
describe("handleGuildMemberRemove", () => {
|
||||||
it("sets inGuild to false for the matching guild", async () => {
|
it("sets inGuild to false for the matching guild", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 });
|
||||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
await handleGuildMemberRemove("user123", discordGuildId);
|
await handleGuildMemberRemove("user123", "guild123");
|
||||||
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
expect(prisma.player.updateMany).toHaveBeenCalledWith({
|
||||||
data: { inGuild: false },
|
data: { inGuild: false },
|
||||||
where: { discordId: "user123" },
|
where: { discordId: "user123" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-ops when guild id does not match the configured guild", async () => {
|
it("no-ops when guild id does not match", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
await handleGuildMemberRemove("user123", "other_guild");
|
await handleGuildMemberRemove("user123", "other_guild");
|
||||||
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("no-ops when DISCORD_GUILD_ID env var is missing", async () => {
|
||||||
|
delete process.env["DISCORD_GUILD_ID"];
|
||||||
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
|
await handleGuildMemberRemove("user123", "guild123");
|
||||||
|
expect(prisma.player.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("logs error when prisma throws an Error", async () => {
|
it("logs error when prisma throws an Error", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
const dbError = new Error("DB failure");
|
const dbError = new Error("DB failure");
|
||||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError);
|
||||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
const { logger } = await import("../../src/services/logger.js");
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
await handleGuildMemberRemove("user123", discordGuildId);
|
await handleGuildMemberRemove("user123", "guild123");
|
||||||
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
|
expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs error when prisma throws a non-Error", async () => {
|
it("logs error when prisma throws a non-Error", async () => {
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error");
|
||||||
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
const { handleGuildMemberRemove } = await import("../../src/services/gateway.js");
|
||||||
const { logger } = await import("../../src/services/logger.js");
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
await handleGuildMemberRemove("user123", discordGuildId);
|
await handleGuildMemberRemove("user123", "guild123");
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
"gateway_member_remove",
|
"gateway_member_remove",
|
||||||
new Error("raw error"),
|
new Error("raw error"),
|
||||||
|
|||||||
@@ -20,20 +20,42 @@ describe("webhook service", () => {
|
|||||||
describe("grantApotheosisRole", () => {
|
describe("grantApotheosisRole", () => {
|
||||||
it("does nothing when bot token is missing", async () => {
|
it("does nothing when bot token is missing", async () => {
|
||||||
delete process.env["DISCORD_BOT_TOKEN"];
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user123");
|
await grantApotheosisRole("user123");
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls Discord API with correct URL and auth when bot token is set", async () => {
|
it("does nothing when guild id is missing", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||||
|
delete process.env["DISCORD_GUILD_ID"];
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123";
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await grantApotheosisRole("user123");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when role id is missing", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
|
delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"];
|
||||||
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
|
await grantApotheosisRole("user123");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Discord API with correct URL and auth when env vars are set", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456";
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await grantApotheosisRole("user789");
|
await grantApotheosisRole("user789");
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991",
|
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -41,6 +63,8 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("swallows fetch errors gracefully", async () => {
|
it("swallows fetch errors gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
@@ -48,6 +72,8 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("swallows non-Error fetch rejections gracefully", async () => {
|
it("swallows non-Error fetch rejections gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
|
process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r";
|
||||||
mockFetch.mockRejectedValueOnce("raw string error");
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
const { grantApotheosisRole } = await import("../../src/services/webhook.js");
|
||||||
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
await expect(grantApotheosisRole("user")).resolves.toBeUndefined();
|
||||||
@@ -57,6 +83,18 @@ describe("webhook service", () => {
|
|||||||
describe("grantElysianRole", () => {
|
describe("grantElysianRole", () => {
|
||||||
it("does nothing when bot token is missing", async () => {
|
it("does nothing when bot token is missing", async () => {
|
||||||
delete process.env["DISCORD_BOT_TOKEN"];
|
delete process.env["DISCORD_BOT_TOKEN"];
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
|
process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123";
|
||||||
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
|
const result = await grantElysianRole("user123");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when guild id is missing", async () => {
|
||||||
|
process.env["DISCORD_BOT_TOKEN"] = "token";
|
||||||
|
delete process.env["DISCORD_GUILD_ID"];
|
||||||
|
process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123";
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user123");
|
const result = await grantElysianRole("user123");
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
@@ -65,11 +103,12 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("returns true when Discord API responds with ok", async () => {
|
it("returns true when Discord API responds with ok", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
process.env["DISCORD_BOT_TOKEN"] = "bot_token";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "guild123";
|
||||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user789");
|
const result = await grantElysianRole("user789");
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
"https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490",
|
"https://discord.com/api/v10/guilds/guild123/members/user789/roles/1486144823684628490",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
headers: expect.objectContaining({ Authorization: "Bot bot_token" }),
|
||||||
@@ -80,6 +119,7 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("returns true when Discord API responds with 204", async () => {
|
it("returns true when Discord API responds with 204", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 204 });
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user");
|
const result = await grantElysianRole("user");
|
||||||
@@ -88,6 +128,7 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("returns false when Discord API responds with an error status", async () => {
|
it("returns false when Discord API responds with an error status", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user");
|
const result = await grantElysianRole("user");
|
||||||
@@ -96,6 +137,7 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("returns false and swallows fetch errors gracefully", async () => {
|
it("returns false and swallows fetch errors gracefully", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user");
|
const result = await grantElysianRole("user");
|
||||||
@@ -104,6 +146,7 @@ describe("webhook service", () => {
|
|||||||
|
|
||||||
it("returns false and swallows non-Error fetch rejections", async () => {
|
it("returns false and swallows non-Error fetch rejections", async () => {
|
||||||
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
process.env["DISCORD_BOT_TOKEN"] = "tok";
|
||||||
|
process.env["DISCORD_GUILD_ID"] = "g";
|
||||||
mockFetch.mockRejectedValueOnce("raw string error");
|
mockFetch.mockRejectedValueOnce("raw string error");
|
||||||
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
const { grantElysianRole } = await import("../../src/services/webhook.js");
|
||||||
const result = await grantElysianRole("user");
|
const result = await grantElysianRole("user");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.3.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ export const GameProvider = ({
|
|||||||
setSchemaOutdated(data.schemaOutdated);
|
setSchemaOutdated(data.schemaOutdated);
|
||||||
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||||
setCurrentSchemaVersion(data.currentSchemaVersion);
|
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||||
setInGuild(data.inGuild);
|
setInGuild(data.inGuild === true);
|
||||||
|
|
||||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||||
void fetch(`/api/profile/${data.state.player.discordId}`).
|
void fetch(`/api/profile/${data.state.player.discordId}`).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.3.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.3.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user