diff --git a/config/jest.unit.js b/config/jest.unit.js index 782b47e8..72e61059 100644 --- a/config/jest.unit.js +++ b/config/jest.unit.js @@ -9,8 +9,8 @@ module.exports = { // TODO: Add these as we can... "/packages/webamp/", // TODO: Fix config improt so that this can work. - "/packages/skin-database/", "/packages/webamp-modern/src/__tests__/integration*", ], testEnvironment: "jsdom", + setupFiles: ["/packages/skin-database/jest-setup.js"], }; diff --git a/packages/skin-database/api/__tests__/__snapshots__/graphql.test.ts.snap b/packages/skin-database/api/__tests__/__snapshots__/graphql.test.ts.snap new file mode 100644 index 00000000..9fbfd33b --- /dev/null +++ b/packages/skin-database/api/__tests__/__snapshots__/graphql.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Query.fetch_skin_by_md5 (debug data) 1`] = ` +Object { + "fetch_skin_by_md5": Object { + "archive_files": Array [ + Object { + "date": "1995-12-17T11:24:00.000Z", + "file_md5": "a_fake_file_md5", + "filename": null, + "is_directory": false, + "size": null, + "skin": Object { + "md5": "a_fake_md5", + }, + "text_content": null, + "url": "https://zip-worker.jordan1320.workers.dev/zip/a_fake_md5/null", + }, + ], + "average_color": null, + "download_url": "https://cdn.webampskins.org/skins/a_fake_md5.wsz", + "filename": "path.wsz", + "id": 50, + "internet_archive_item": Object { + "identifier": "a_fake_ia_identifier", + "metadata_url": "https://archive.org/metadata/a_fake_ia_identifier", + "raw_metadata_json": null, + "skin": Object { + "md5": "a_fake_md5", + }, + "url": "https://archive.org/details/a_fake_ia_identifier", + }, + "md5": "a_fake_md5", + "museum_url": "https://skins.webamp.org/skin/a_fake_md5", + "nsfw": false, + "readme_text": null, + "reviews": Array [], + "screenshot_url": "https://cdn.webampskins.org/screenshots/a_fake_md5.png", + "tweeted": false, + "tweets": Array [], + "webamp_url": "https://webamp.org?skinUrl=https://cdn.webampskins.org/skins/a_fake_md5.wsz", + }, +} +`; diff --git a/packages/skin-database/api/__tests__/graphql.test.ts b/packages/skin-database/api/__tests__/graphql.test.ts new file mode 100644 index 00000000..b8d2038a --- /dev/null +++ b/packages/skin-database/api/__tests__/graphql.test.ts @@ -0,0 +1,484 @@ +import { Application } from "express"; +import { knex } from "../../db"; +import request from "supertest"; // supertest is a framework that allows to easily test web apis +import { createApp } from "../app"; +import SkinModel from "../../data/SkinModel"; +import * as S3 from "../../s3"; +import * as Auth from "../auth"; +import { processUserUploads } from "../processUserUploads"; +import UserContext from "../../data/UserContext"; +import { searchIndex } from "../../algolia"; +jest.mock("../../s3"); +jest.mock("../../algolia"); +jest.mock("../processUserUploads"); +jest.mock("../auth"); + +let app: Application; +const handler = jest.fn(); +const log = jest.fn(); +const logError = jest.fn(); + +let username: string | undefined; + +beforeEach(async () => { + jest.clearAllMocks(); + username = ""; + app = createApp({ + eventHandler: handler, + extraMiddleware: (req, res, next) => { + req.session.username = username; + next(); + }, + logger: { log, logError }, + }); + await knex.migrate.latest(); + await knex.seed.run(); +}); + +function gql(templateString: TemplateStringsArray): string { + return templateString[0]; +} + +async function graphQLRequest(query: string, variables?: any) { + const { body } = await request(app) + .post("/graphql") + .send({ query, variables: variables ?? {} }); + if (body.errors && body.errors.length) { + console.warn(body); + } + + return body; +} + +describe(".me", () => { + test("logged in ", async () => { + const { data } = await graphQLRequest(gql` + query { + me { + username + } + } + `); + expect(data).toEqual({ me: { username: "" } }); + }); + test("not logged in", async () => { + username = undefined; + const { data } = await graphQLRequest(gql` + query { + me { + username + } + } + `); + expect(data).toEqual({ me: { username: null } }); + }); +}); + +test("/auth", async () => { + const { body } = await request(app) + .get("/auth") + .expect(302) + .expect( + "Location", + "https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds" + ); + expect(body).toEqual({}); +}); + +describe("/auth/discord", () => { + test("valid code", async () => { + const response = await request(app) + .get("/auth/discord") + .query({ code: "" }) + .expect(302) + .expect("Location", "https://skins.webamp.org/review/"); + // TODO: Assert that we get cookie headers. I think that will not work now + // because express does not think it's secure in a test env. + expect(Auth.auth).toHaveBeenCalledWith(""); + expect(response.body).toEqual({}); + }); + test("missing code", async () => { + const { body } = await request(app).get("/auth/discord").expect(400); + expect(Auth.auth).not.toHaveBeenCalled(); + expect(body).toEqual({ message: "Expected to get a code" }); + }); +}); + +describe("Query.skins", () => { + test("no query params", async () => { + const { data } = await graphQLRequest( + gql` + query { + skins(sort: MUSEUM) { + count + nodes { + md5 + filename + nsfw + } + } + } + ` + ); + expect(data.skins).toMatchInlineSnapshot(` + Object { + "count": 6, + "nodes": Array [ + Object { + "filename": "Zelda_Amp_3.wsz", + "md5": "48bbdbbeb03d347e59b1eebda4d352d0", + "nsfw": false, + }, + Object { + "filename": "path.wsz", + "md5": "a_fake_md5", + "nsfw": false, + }, + Object { + "filename": "tweeted.wsz", + "md5": "a_tweeted_md5", + "nsfw": false, + }, + Object { + "filename": "approved.wsz", + "md5": "an_approved_md5", + "nsfw": false, + }, + Object { + "filename": "rejected.wsz", + "md5": "a_rejected_md5", + "nsfw": false, + }, + Object { + "filename": "nsfw.wsz", + "md5": "a_nsfw_md5", + "nsfw": true, + }, + ], + } + `); + }); + test("first and offset", async () => { + const { data } = await graphQLRequest( + gql` + query MyQuery($first: Int, $offset: Int) { + skins(first: $first, offset: $offset, sort: MUSEUM) { + count + nodes { + md5 + filename + nsfw + } + } + } + `, + { first: 2, offset: 1 } + ); + expect(data.skins).toMatchInlineSnapshot(` + Object { + "count": 6, + "nodes": Array [ + Object { + "filename": "path.wsz", + "md5": "a_fake_md5", + "nsfw": false, + }, + Object { + "filename": "tweeted.wsz", + "md5": "a_tweeted_md5", + "nsfw": false, + }, + ], + } + `); + }); +}); + +test("Query.fetch_skin_by_md5 (debug data)", async () => { + const { data } = await graphQLRequest( + gql` + query MyQuery($md5: String!) { + fetch_skin_by_md5(md5: $md5) { + id + md5 + museum_url + webamp_url + screenshot_url + download_url + filename + readme_text + nsfw + average_color + tweeted + tweets { + url + } + archive_files { + filename + url + date + file_md5 + size + text_content + is_directory + skin { + md5 + } + } + filename + internet_archive_item { + identifier + url + metadata_url + raw_metadata_json + skin { + md5 + } + } + reviews { + skin { + md5 + } + reviewer + rating + } + } + } + `, + { md5: "a_fake_md5" } + ); + expect(data).toMatchSnapshot(); +}); + +test("Mutation.request_nsfw_review_for_skin", async () => { + const { data } = await graphQLRequest( + gql` + mutation ($md5: String!) { + request_nsfw_review_for_skin(md5: $md5) + } + `, + { md5: "a_fake_md5" } + ); + expect(handler).toHaveBeenCalledWith({ + type: "REVIEW_REQUESTED", + md5: "a_fake_md5", + }); + expect(data).toEqual({ request_nsfw_review_for_skin: true }); +}); + +test("Mutation.approve_skin", async () => { + const ctx = new UserContext(); + const { data } = await graphQLRequest( + gql` + mutation ($md5: String!) { + approve_skin(md5: $md5) + } + `, + { md5: "a_fake_md5" } + ); + expect(handler).toHaveBeenCalledWith({ + type: "APPROVED_SKIN", + md5: "a_fake_md5", + }); + expect(data).toEqual({ approve_skin: true }); + const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); + + expect(await skin?.getTweetStatus()).toEqual("APPROVED"); +}); + +test("Mutation.reject_skin", async () => { + const ctx = new UserContext(); + const { data } = await graphQLRequest( + gql` + mutation ($md5: String!) { + reject_skin(md5: $md5) + } + `, + { md5: "a_fake_md5" } + ); + expect(handler).toHaveBeenCalledWith({ + type: "REJECTED_SKIN", + md5: "a_fake_md5", + }); + expect(data).toEqual({ reject_skin: true }); + const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); + + expect(await skin?.getTweetStatus()).toEqual("REJECTED"); +}); + +test("Mutation.mark_skin_nsfw", async () => { + const ctx = new UserContext(); + const { data } = await graphQLRequest( + gql` + mutation ($md5: String!) { + mark_skin_nsfw(md5: $md5) + } + `, + { md5: "a_fake_md5" } + ); + expect(handler).toHaveBeenCalledWith({ + type: "MARKED_SKIN_NSFW", + md5: "a_fake_md5", + }); + expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([ + { nsfw: true, objectID: "a_fake_md5" }, + ]); + expect(data).toEqual({ mark_skin_nsfw: true }); + const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); + + expect(await skin?.getTweetStatus()).toEqual("NSFW"); +}); + +describe("Query.skin_to_review", () => { + test("logged in ", async () => { + const { data } = await graphQLRequest( + gql` + query { + skin_to_review { + md5 + filename + } + } + ` + ); + + expect(data).toEqual({ + skin_to_review: { + filename: expect.any(String), + md5: expect.any(String), + }, + }); + }); + test("not logged in ", async () => { + username = undefined; + const { data } = await graphQLRequest( + gql` + query { + skin_to_review { + md5 + filename + } + } + ` + ); + expect(data).toEqual({ skin_to_review: null }); + }); +}); + +// TODO: Actually upload some skins? +test("Query.upload_statuses_by_md5", async () => { + const { data } = await graphQLRequest( + gql` + query ($md5s: [String!]!) { + upload_statuses_by_md5(md5s: $md5s) { + id + status + upload_md5 + } + } + `, + { md5s: ["a_fake_md5", "a_missing_md5"] } + ); + expect(data.upload_statuses_by_md5).toEqual([]); +}); + +test("Mutation.upload.get_upload_urls", async () => { + const { data } = await graphQLRequest( + gql` + mutation ($files: [UploadUrlRequest!]!) { + upload { + get_upload_urls(files: $files) { + id + url + md5 + } + } + } + `, + { + files: [ + { + md5: "3b73bcd43c30b85d4cad3083e8ac9695", + filename: "a_fake_new_file.wsz", + }, + { + md5: "48bbdbbeb03d347e59b1eebda4d352d0", + filename: "a_new_name_for_a_file_that_exists.wsz", + }, + ], + } + ); + + expect(S3.getSkinUploadUrl).toHaveBeenCalledWith( + "3b73bcd43c30b85d4cad3083e8ac9695", + expect.any(Number) + ); + + expect(data.upload.get_upload_urls).toEqual([ + { + md5: "3b73bcd43c30b85d4cad3083e8ac9695", + id: expect.any(String), + url: "", + }, + ]); +}); + +test("An Upload Flow", async () => { + // Request an upload URL + const md5 = "3b73bcd43c30b85d4cad3083e8ac9695"; + const filename = "a_fake_new_file.wsz"; + const { data } = await graphQLRequest( + gql` + mutation ($files: [UploadUrlRequest!]!) { + upload { + get_upload_urls(files: $files) { + id + url + md5 + } + } + } + `, + { files: [{ md5, filename }] } + ); + + expect(data.upload.get_upload_urls).toEqual([ + { + md5, + id: expect.any(String), + url: "", + }, + ]); + + const id = data.upload.get_upload_urls[0].id; + + const requestedUpload = await knex("skin_uploads").where({ id }).first(); + expect(requestedUpload).toEqual({ + filename, + id: Number(id), + skin_md5: md5, + status: "URL_REQUESTED", + }); + + // Report that we've uploaded the skin to S3 (we lie) + const { data: uploadReportData } = await graphQLRequest( + gql` + mutation ($id: String!, $md5: String!) { + upload { + report_skin_uploaded(id: $id, md5: $md5) + } + } + `, + { md5, id } + ); + expect(uploadReportData.upload.report_skin_uploaded).toEqual(true); + expect(processUserUploads).toHaveBeenCalled(); + + const reportedUpload = await knex("skin_uploads").where({ id }).first(); + expect(reportedUpload).toEqual({ + filename, + id: Number(id), + skin_md5: md5, + status: "UPLOAD_REPORTED", + }); +}); diff --git a/packages/skin-database/api/__tests__/router.test.ts b/packages/skin-database/api/__tests__/router.test.ts index 5219dd7d..ba3f84d8 100644 --- a/packages/skin-database/api/__tests__/router.test.ts +++ b/packages/skin-database/api/__tests__/router.test.ts @@ -53,7 +53,7 @@ test("/auth", async () => { .expect(302) .expect( "Location", - "https://discord.com/api/oauth2/authorize?client_id=560264562222432304&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds" + "https://discord.com/api/oauth2/authorize?client_id=%3CDUMMY_DISCORD_CLIENT_ID%3E&redirect_uri=https%3A%2F%2Fapi.webampskins.org%2Fauth%2Fdiscord&response_type=code&scope=identify%20guilds" ); expect(body).toEqual({}); }); diff --git a/packages/skin-database/api/app.ts b/packages/skin-database/api/app.ts index 6439453a..1943781d 100644 --- a/packages/skin-database/api/app.ts +++ b/packages/skin-database/api/app.ts @@ -84,6 +84,8 @@ export function createApp({ eventHandler, extraMiddleware, logger }: Options) { name: "session", secret: SECRET, maxAge: 24 * 60 * 60 * 1000, // 24 hours + // @ts-ignore Tests fail if this is missing, but prod is fine. + keys: "what", }) ); diff --git a/packages/skin-database/jest-setup.js b/packages/skin-database/jest-setup.js new file mode 100644 index 00000000..2fcbcfff --- /dev/null +++ b/packages/skin-database/jest-setup.js @@ -0,0 +1,27 @@ +// In the real app, these are set via .env +process.env.LOCAL_FILE_CACHE = ""; +process.env.CLOUDFLARE_PURGE_AUTH_KEY = ""; +process.env.CAPTBARITONE_USER_ID = ""; +process.env.TEST_CHANNEL_ID = ""; +process.env.TWEET_BOT_CHANNEL_ID = ""; +process.env.SKIN_UPLOADS_CHANNEL_ID = ""; +process.env.SKIN_REVIEW_CHANNEL_ID = ""; +process.env.NSFW_SKIN_CHANNEL_ID = ""; +process.env.FEEDBACK_SKIN_CHANNEL_ID = ""; +process.env.POPULAR_TWEETS_CHANNEL_ID = ""; +process.env.DISCORD_TOKEN = ""; +process.env.DISCORD_WEBAMP_SERVER_ID = ""; +process.env.DISCORD_CLIENT_ID = ""; +process.env.DISCORD_CLIENT_SECRET = ""; +process.env.DISCORD_REDIRECT_URL = "https://api.webampskins.org/auth/discord"; +process.env.LOGIN_REDIRECT_URL = "https://skins.webamp.org/review/"; +process.env.ALGOLIA_ACCOUNT = ""; +process.env.ALGOLIA_INDEX = ""; +process.env.ALGOLIA_KEY = ""; +process.env.TWITTER_API_KEY = ""; +process.env.TWITTER_API_SECRET = ""; +process.env.TWITTER_ACCESS_TOKEN = ""; +process.env.TWITTER_ACCESS_TOKEN_SECRET = ""; +process.env.INSTAGRAM_ACCESS_TOKEN = ""; +process.env.INSTAGRAM_ACCOUNT_ID = ""; +process.env.SECRET = ""; diff --git a/packages/skin-database/knexfile.ts b/packages/skin-database/knexfile.ts index a1b82f48..147cd18a 100644 --- a/packages/skin-database/knexfile.ts +++ b/packages/skin-database/knexfile.ts @@ -1,16 +1,15 @@ import path from "path"; -import { PROJECT_ROOT } from "./config"; const production = { client: "sqlite3", connection: { - filename: path.join(PROJECT_ROOT, "./skins.sqlite3"), + filename: path.join(__dirname, "./skins.sqlite3"), }, useNullAsDefault: true, debug: false, migrations: { tableName: "knex_migrations", - directory: path.join(PROJECT_ROOT, "./migrations"), + directory: path.join(__dirname, "./migrations"), }, }; @@ -20,14 +19,14 @@ const configs = { connection: ":memory:", seeds: { // Only put this in the test config so that we ensure we never clobber prod data. - directory: path.join(PROJECT_ROOT, "./seeds"), + directory: path.join(__dirname, "./seeds"), }, }, development: { ...production, connection: { ...production.connection, - filename: path.join(PROJECT_ROOT, "./skins-dev.sqlite3"), + filename: path.join(__dirname, "./skins-dev.sqlite3"), }, }, production,