restructure tests, add tests, remove dotenv (#83)
- add a few tests - re-organize tests to allow testing with different env vars and creating a combined coverage report - extract test data into separate files, because importing data from testA in testB leads to weird jest errors - remove `dotenv` because it's not being used - "GitHub Trends" -> "GitHub Stats Extended"
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
|
||||
import gistCard from "./api-renamed/gist.js";
|
||||
|
||||
@@ -3,11 +3,6 @@ export default {
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
coverageProvider: "v8",
|
||||
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
modulePathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/tests/e2e/"],
|
||||
coveragePathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/e2e/",
|
||||
],
|
||||
testRegex: "(\\.bench)\\.(ts|tsx|js)$",
|
||||
testMatch: ["<rootDir>/tests/bench/*.bench.{ts,js}"],
|
||||
setupFiles: ["<rootDir>/tests/setup.jest.js"],
|
||||
};
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
coverageProvider: "v8",
|
||||
setupFiles: ["<rootDir>/tests/setup.jest.js"],
|
||||
testPathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/e2e/",
|
||||
"<rootDir>/.vercel/",
|
||||
],
|
||||
modulePathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/e2e/",
|
||||
"<rootDir>/.vercel/",
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
"<rootDir>/node_modules/",
|
||||
"<rootDir>/tests/E2E/",
|
||||
"<rootDir>/.vercel/",
|
||||
projects: [
|
||||
"<rootDir>/jest.public-instance.config.js",
|
||||
"<rootDir>/jest.private-instance.config.js",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,5 +3,5 @@ export default {
|
||||
transform: {},
|
||||
testEnvironment: "node",
|
||||
coverageProvider: "v8",
|
||||
testMatch: ["<rootDir>/tests/e2e/**/*.test.js"],
|
||||
testMatch: ["<rootDir>/tests/e2e/*.test.{ts,js}"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
testMatch: [
|
||||
"<rootDir>/tests/*.test.{ts,js}",
|
||||
"<rootDir>/tests/private-instance/*.test.{ts,js}",
|
||||
],
|
||||
setupFiles: [
|
||||
"<rootDir>/tests/setup.jest.js",
|
||||
"<rootDir>/tests/setup.private-instance.jest.js",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
clearMocks: true,
|
||||
transform: {},
|
||||
testEnvironment: "jsdom",
|
||||
testMatch: [
|
||||
"<rootDir>/tests/*.test.{ts,js}",
|
||||
"<rootDir>/tests/public-instance/*.test.{ts,js}",
|
||||
],
|
||||
setupFiles: ["<rootDir>/tests/setup.jest.js"],
|
||||
};
|
||||
@@ -43,7 +43,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"emoji-name-map": "^2.0.3",
|
||||
"github-username-regex": "^1.0.0",
|
||||
"pg": "^8.16.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import axios from "axios";
|
||||
import * as dotenv from "dotenv";
|
||||
import githubUsernameRegex from "github-username-regex";
|
||||
|
||||
import { calculateRank } from "../calculateRank.js";
|
||||
@@ -13,8 +12,6 @@ import { logger } from "../common/log.js";
|
||||
import { buildSearchFilter, parseOwnerAffiliations } from "../common/ops.js";
|
||||
import { retryer } from "../common/retryer.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// GraphQL queries.
|
||||
const GRAPHQL_REPOS_FIELD = `
|
||||
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Test /api/wakatime should render error if user data is not accessible 1`] = `
|
||||
"
|
||||
<svg
|
||||
width="495"
|
||||
height="150"
|
||||
viewBox="0 0 495 150"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-labelledby="descId"
|
||||
>
|
||||
<title id="titleId"></title>
|
||||
<desc id="descId"></desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect
|
||||
data-testid="card-bg"
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
rx="4.5"
|
||||
height="99%"
|
||||
stroke="#e4e2e2"
|
||||
width="494"
|
||||
fill="#fffefe"
|
||||
stroke-opacity="1"
|
||||
/>
|
||||
|
||||
|
||||
<g
|
||||
data-testid="card-title"
|
||||
transform="translate(25, 35)"
|
||||
>
|
||||
<g transform="translate(0, 0)">
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
class="header"
|
||||
data-testid="header"
|
||||
>WakaTime Stats</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g
|
||||
data-testid="main-card-body"
|
||||
transform="translate(0, 55)"
|
||||
>
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
"
|
||||
`;
|
||||
@@ -17,23 +17,7 @@ import { renderStatsCard } from "../src/cards/stats.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
/**
|
||||
* @type {import("../src/fetchers/stats").StatsData}
|
||||
*/
|
||||
const stats = {
|
||||
name: "Anurag Hazra",
|
||||
totalStars: 100,
|
||||
totalCommits: 200,
|
||||
totalIssues: 300,
|
||||
totalPRs: 400,
|
||||
totalPRsMerged: 320,
|
||||
mergedPRsPercentage: 80,
|
||||
totalReviews: 50,
|
||||
totalDiscussionsStarted: 10,
|
||||
totalDiscussionsAnswered: 40,
|
||||
contributedTo: 50,
|
||||
rank: { level: "DEV", percentile: 0 },
|
||||
};
|
||||
import { data_stats, stats } from "./test-data/api-data.js";
|
||||
|
||||
stats.rank = calculateRank({
|
||||
all_commits: false,
|
||||
@@ -46,38 +30,6 @@ stats.rank = calculateRank({
|
||||
followers: 0,
|
||||
});
|
||||
|
||||
const data_stats = {
|
||||
data: {
|
||||
user: {
|
||||
name: stats.name,
|
||||
repositoriesContributedTo: { totalCount: stats.contributedTo },
|
||||
commits: {
|
||||
totalCommitContributions: stats.totalCommits,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: stats.totalReviews,
|
||||
},
|
||||
pullRequests: { totalCount: stats.totalPRs },
|
||||
mergedPullRequests: { totalCount: stats.totalPRsMerged },
|
||||
openIssues: { totalCount: stats.totalIssues },
|
||||
closedIssues: { totalCount: 0 },
|
||||
followers: { totalCount: 0 },
|
||||
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
|
||||
repositoryDiscussionComments: {
|
||||
totalCount: stats.totalDiscussionsAnswered,
|
||||
},
|
||||
repositories: {
|
||||
totalCount: 1,
|
||||
nodes: [{ stargazers: { totalCount: 100 } }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const error = {
|
||||
errors: [
|
||||
{
|
||||
@@ -357,21 +309,6 @@ describe("Test /api/", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const { req, res } = faker({ username: "renovate-bot" }, data_stats);
|
||||
|
||||
await api(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card when wrong locale is provided", async () => {
|
||||
const { req, res } = faker({ locale: "asdf" }, data_stats);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { beforeAll, describe, expect, test } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
import { renderGistCard } from "../../src/cards/gist.js";
|
||||
import { renderRepoCard } from "../../src/cards/repo.js";
|
||||
@@ -12,8 +11,6 @@ import { renderStatsCard } from "../../src/cards/stats.js";
|
||||
import { renderTopLanguages } from "../../src/cards/top-languages.js";
|
||||
import { renderWakatimeCard } from "../../src/cards/wakatime.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const REPO = "curly-fiesta";
|
||||
const USER = "catelinemnemosyne";
|
||||
const STATS_CARD_USER = "e2eninja";
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("Test fetchGist", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if reaponse contains them", async () => {
|
||||
it("should throw error if response contains them", async () => {
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data);
|
||||
|
||||
await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow(
|
||||
|
||||
@@ -10,32 +10,7 @@ import { renderGistCard } from "../src/cards/gist.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
const gist_data = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: {
|
||||
description:
|
||||
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
|
||||
owner: {
|
||||
login: "Yizack",
|
||||
},
|
||||
stargazerCount: 33,
|
||||
forks: {
|
||||
totalCount: 11,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: "countries.json",
|
||||
language: {
|
||||
name: "JSON",
|
||||
},
|
||||
size: 85858,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { gist_data } from "./test-data/gist-data.js";
|
||||
|
||||
const gist_not_found_data = {
|
||||
data: {
|
||||
@@ -114,27 +89,6 @@ describe("Test /api/gist", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error if id is not provided", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
|
||||
await gist(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: 'Missing params "id" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/gist?id=GIST_ID",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error if gist is not found", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
@@ -168,6 +122,7 @@ describe("Test /api/gist", () => {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
|
||||
await gist(req, res);
|
||||
|
||||
|
||||
@@ -12,12 +12,9 @@ import {
|
||||
} from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
import patInfo, { RATE_LIMIT_SECONDS } from "../api-renamed/status/pat-info.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const successData = {
|
||||
@@ -73,7 +70,7 @@ afterEach(() => {
|
||||
|
||||
describe("Test /api/status/pat-info", () => {
|
||||
beforeAll(() => {
|
||||
// reset patenv first so that dotenv doesn't populate them with local envs
|
||||
// reset patenv first so that they are not populated with local envs
|
||||
process.env = {};
|
||||
process.env.PAT_1 = "testPAT1";
|
||||
process.env.PAT_2 = "testPAT2";
|
||||
|
||||
@@ -10,30 +10,7 @@ import { renderRepoCard } from "../src/cards/repo.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
const data_repo = {
|
||||
repository: {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
description: "Help us take over the world! React + TS + GraphQL Chat App",
|
||||
primaryLanguage: {
|
||||
color: "#2b7489",
|
||||
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||
name: "TypeScript",
|
||||
},
|
||||
forkCount: 100,
|
||||
isTemplate: false,
|
||||
},
|
||||
};
|
||||
|
||||
const data_user = {
|
||||
data: {
|
||||
user: { repository: data_repo.repository },
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
import { data_repo, data_user } from "./test-data/pin-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
@@ -146,31 +123,6 @@ describe("Test /api/pin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
repo: "convoychat",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if wrong locale provided", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
@@ -196,28 +148,6 @@ describe("Test /api/pin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if missing required parameters", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message:
|
||||
'Missing params "username", "repo" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/pin?username=USERNAME&repo=REPO_NAME",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should have proper cache", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import api from "../../api-renamed/index.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_stats } from "../test-data/api-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/", () => {
|
||||
it("should render error card if username not in whitelist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
|
||||
|
||||
await api(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is not whitelisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import gist from "../../api-renamed/gist.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { gist_data } from "../test-data/gist-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/gist with gist whitelist", () => {
|
||||
it("should render error card if id not in whitelist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
id: "9bae0392ee3a26bac5cc388a6c8b1469",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
|
||||
await gist(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This gist ID is not whitelisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import pin from "../../api-renamed/pin.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_user } from "../test-data/pin-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/pin", () => {
|
||||
it("should render error card if username not in whitelist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
repo: "convoychat",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is not whitelisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if missing required parameters", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is not whitelisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import topLangs from "../../api-renamed/top-langs.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_langs } from "../test-data/langs-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs", () => {
|
||||
it("should render error card if username not in whitelist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
|
||||
await topLangs(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is not whitelisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import api from "../../api-renamed/index.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_stats } from "../test-data/api-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").replyOnce(200, data_stats);
|
||||
|
||||
await api(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import gist from "../../api-renamed/gist.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { gist_data } from "../test-data/gist-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/gist", () => {
|
||||
it("should render error if id is not provided", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
|
||||
|
||||
await gist(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: 'Missing params "id" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/gist?id=GIST_ID",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import pin from "../../api-renamed/pin.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_user } from "../test-data/pin-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/pin", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
repo: "convoychat",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if missing required parameters", async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_user);
|
||||
|
||||
await pin(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message:
|
||||
'Missing params "username", "repo" make sure you pass the parameters in URL',
|
||||
secondaryMessage: "/api/pin?username=USERNAME&repo=REPO_NAME",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
|
||||
import topLangs from "../../api-renamed/top-langs.js";
|
||||
import { renderError } from "../../src/common/render.js";
|
||||
import { data_langs } from "../test-data/langs-data.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs", () => {
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
|
||||
await topLangs(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
//https://stackoverflow.com/a/68468204/1643179
|
||||
|
||||
import { TextDecoder, TextEncoder } from "util";
|
||||
|
||||
//https://stackoverflow.com/a/68468204/1643179
|
||||
Object.assign(global, { TextDecoder, TextEncoder });
|
||||
|
||||
process.env.PAT_1 = "dummyPAT1";
|
||||
process.env.PAT_2 = "dummyPAT2";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
process.env.GIST_WHITELIST = "bbfce31e0217a3689c8d961a356cb10d";
|
||||
process.env.WHITELIST = "anuraghazra";
|
||||
@@ -0,0 +1,51 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @type {import("../../src/fetchers/stats").StatsData}
|
||||
*/
|
||||
export const stats = {
|
||||
name: "Anurag Hazra",
|
||||
totalStars: 100,
|
||||
totalCommits: 200,
|
||||
totalIssues: 300,
|
||||
totalPRs: 400,
|
||||
totalPRsMerged: 320,
|
||||
mergedPRsPercentage: 80,
|
||||
totalReviews: 50,
|
||||
totalDiscussionsStarted: 10,
|
||||
totalDiscussionsAnswered: 40,
|
||||
contributedTo: 50,
|
||||
rank: { level: "DEV", percentile: 0 },
|
||||
};
|
||||
|
||||
export const data_stats = {
|
||||
data: {
|
||||
user: {
|
||||
name: stats.name,
|
||||
repositoriesContributedTo: { totalCount: stats.contributedTo },
|
||||
commits: {
|
||||
totalCommitContributions: stats.totalCommits,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: stats.totalReviews,
|
||||
},
|
||||
pullRequests: { totalCount: stats.totalPRs },
|
||||
mergedPullRequests: { totalCount: stats.totalPRsMerged },
|
||||
openIssues: { totalCount: stats.totalIssues },
|
||||
closedIssues: { totalCount: 0 },
|
||||
followers: { totalCount: 0 },
|
||||
repositoryDiscussions: { totalCount: stats.totalDiscussionsStarted },
|
||||
repositoryDiscussionComments: {
|
||||
totalCount: stats.totalDiscussionsAnswered,
|
||||
},
|
||||
repositories: {
|
||||
totalCount: 1,
|
||||
nodes: [{ stargazers: { totalCount: 100 } }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// @ts-check
|
||||
|
||||
export const gist_data = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: {
|
||||
description:
|
||||
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
|
||||
owner: {
|
||||
login: "Yizack",
|
||||
},
|
||||
stargazerCount: 33,
|
||||
forks: {
|
||||
totalCount: 11,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: "countries.json",
|
||||
language: {
|
||||
name: "JSON",
|
||||
},
|
||||
size: 85858,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// @ts-check
|
||||
|
||||
export const data_langs = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// @ts-check
|
||||
|
||||
export const data_repo = {
|
||||
repository: {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
description: "Help us take over the world! React + TS + GraphQL Chat App",
|
||||
primaryLanguage: {
|
||||
color: "#2b7489",
|
||||
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||
name: "TypeScript",
|
||||
},
|
||||
forkCount: 100,
|
||||
isTemplate: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const data_user = {
|
||||
data: {
|
||||
user: { repository: data_repo.repository },
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
@@ -10,40 +10,7 @@ import { renderTopLanguages } from "../src/cards/top-languages.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/common/render.js";
|
||||
|
||||
const data_langs = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 150, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [{ size: 100, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 100, node: { color: "#0ff", name: "javascript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { data_langs } from "./test-data/langs-data.js";
|
||||
|
||||
const error = {
|
||||
errors: [
|
||||
@@ -175,30 +142,6 @@ describe("Test /api/top-langs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if username in blacklist", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
username: "renovate-bot",
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
setHeader: jest.fn(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
|
||||
|
||||
await topLangs(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "This username is blacklisted",
|
||||
secondaryMessage: "Please deploy your own instance",
|
||||
renderOptions: { show_repo_link: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error card if wrong locale provided", async () => {
|
||||
const req = {
|
||||
query: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import MockAdapter from "axios-mock-adapter";
|
||||
import wakatime from "../api-renamed/wakatime.js";
|
||||
import { renderWakatimeCard } from "../src/cards/wakatime.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { renderError } from "../src/index.js";
|
||||
|
||||
const wakaTimeData = {
|
||||
data: {
|
||||
@@ -99,6 +100,14 @@ const wakaTimeData = {
|
||||
},
|
||||
};
|
||||
|
||||
const wakaTimeNotFoundData = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
afterEach(() => {
|
||||
@@ -124,6 +133,44 @@ describe("Test /api/wakatime", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error if wrong locale is provided", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username, locale: "asdf" } };
|
||||
const res = { setHeader: jest.fn(), send: jest.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
|
||||
await wakatime(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
renderError({
|
||||
message: "Something went wrong",
|
||||
secondaryMessage: "Language not found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render error if user data is not accessible", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username } };
|
||||
const res = { setHeader: jest.fn(), send: jest.fn() };
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, wakaTimeNotFoundData);
|
||||
|
||||
await wakatime(req, res);
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "image/svg+xml");
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(res.send.mock.calls[0][0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should have proper cache", async () => {
|
||||
const username = "anuraghazra";
|
||||
const req = { query: { username } };
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Safe browser stub for dotenv
|
||||
export function config(): { parsed: Record<string, never> } {
|
||||
return { parsed: {} };
|
||||
}
|
||||
@@ -52,7 +52,8 @@ export function LoginAccountDeleteModal(
|
||||
<hr />
|
||||
<br />
|
||||
<p>
|
||||
Are you sure you want to delete your account from GitHub Trends?
|
||||
Are you sure you want to delete your account from GitHub Stats
|
||||
Extended?
|
||||
</p>
|
||||
<br />
|
||||
<div className="flex flex-wrap">
|
||||
|
||||
@@ -61,13 +61,6 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "dotenv",
|
||||
replacement: path.resolve(
|
||||
import.meta.dirname,
|
||||
"src/dotenv-browser-stub.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: "../src/fetchers/wakatime.js",
|
||||
replacement: path.resolve(
|
||||
|
||||
+9
-5
@@ -2,6 +2,14 @@
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
"apps/backend": {
|
||||
"jest": {
|
||||
"config": [
|
||||
"jest.config.js",
|
||||
"jest.*.config.js",
|
||||
"package.json" // included by default, see https://knip.dev/reference/plugins/jest
|
||||
]
|
||||
},
|
||||
|
||||
"entry": [
|
||||
"api-renamed/*.js",
|
||||
"express.js",
|
||||
@@ -17,11 +25,7 @@
|
||||
]
|
||||
},
|
||||
"apps/frontend": {
|
||||
"entry": [
|
||||
"src/index.tsx",
|
||||
"src/dotenv-browser-stub.ts", // referenced by vite.config.ts
|
||||
"src/wakatime-override.ts"
|
||||
],
|
||||
"entry": ["src/index.tsx", "src/wakatime-override.ts"],
|
||||
|
||||
"ignoreDependencies": [
|
||||
// below dependencies are added because backend folder is copied inside frontend folder,
|
||||
|
||||
Generated
-9
@@ -74,9 +74,6 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.13.5
|
||||
version: 1.13.5
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
emoji-name-map:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
@@ -2096,10 +2093,6 @@ packages:
|
||||
resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6406,8 +6399,6 @@ snapshots:
|
||||
|
||||
domain-browser@4.22.0: {}
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
||||
Reference in New Issue
Block a user