Skip to content
Merged
48 changes: 48 additions & 0 deletions backend/src/graphql/resolvers/galleryResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ type Context = {

export function createGalleryResolvers(db: Db) {
return {
gallery: async (
_parent: unknown,
{ id, limit, offset }: { id: string; limit: number; offset: number },
context: Context,
) => {
if (!context.userId || !ObjectId.isValid(context.userId)) {
throw new GraphQLError("You must be logged in");
}

if (limit < 1 || limit > 100 || offset < 0) {
throw new GraphQLError("Invalid pagination parameters");
}

if (!ObjectId.isValid(id)) {
return null;
}

const gallery = await db.collection("galleries").findOne({
_id: new ObjectId(id),
userId: new ObjectId(context.userId),
});

if (!gallery) {
return null;
}

const collection = db.collection("met_artworks");
const filter = {
id: { $in: gallery.artworkIds ?? [] },
};

const [artworks, totalCount] = await Promise.all([
collection
.find(filter)
.sort({ id: 1 })
.skip(offset)
.limit(limit)
.toArray(),
collection.countDocuments(filter),
]);

return {
id: gallery._id.toString(),
title: gallery.title,
artworks,
totalCount,
};
},
myGalleries: async (_parent: unknown, _args: unknown, context: Context) => {
if (!context.userId || !ObjectId.isValid(context.userId)) {
throw new GraphQLError("You must be logged in");
Expand Down
1 change: 1 addition & 0 deletions backend/src/graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function createResolvers(db: Db) {
hello: () => "Hello from Artex backend!",
...createArtworkResolvers(db),
myGalleries: galleryResolvers.myGalleries,
gallery: galleryResolvers.gallery,
me: userResolvers.me,
},

Expand Down
13 changes: 13 additions & 0 deletions backend/src/graphql/typeDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const typeDefs = `#graphql
artworkIds: [Int!]!
}
type GalleryPage {
id: ID!
title: String!
artworks: [Artwork!]!
totalCount: Int!
}
enum ArtworkSort {
TITLE_ASC
TITLE_DESC
Expand Down Expand Up @@ -68,6 +75,12 @@ export const typeDefs = `#graphql
): [String!]!
myGalleries: [Gallery!]!
gallery(
id: ID!
limit: Int!
offset: Int!
): GalleryPage
}
type Mutation {
Expand Down
301 changes: 301 additions & 0 deletions backend/tests/gallery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ApolloServer } from "@apollo/server";
import { MongoClient, ObjectId } from "mongodb";
import { randomUUID } from "node:crypto";
import { typeDefs } from "../src/graphql/typeDefs.js";
import { createResolvers } from "../src/graphql/resolvers/index.js";

type TestContext = {
userId: string | null;
};

const client = new MongoClient(
process.env.TEST_MONGODB_URI ?? "mongodb://127.0.0.1:27018",
{
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 5000,
},
);

const database = client.db(`artex_gallery_test_${randomUUID()}`);

const ownerId = new ObjectId();
const otherUserId = new ObjectId();
const galleryId = new ObjectId();
const emptyGalleryId = new ObjectId();

const server = new ApolloServer<TestContext>({
typeDefs,
resolvers: createResolvers(database),
});

beforeAll(async () => {
await client.connect();

await database.collection("met_artworks").insertMany([
{ id: 30, title: "Third artwork" },
{ id: 10, title: "First artwork" },
{ id: 20, title: "Second artwork" },
{ id: 40, title: "Artwork outside the gallery" },
]);

await database.collection("galleries").insertMany([
{
_id: galleryId,
userId: ownerId,
title: "Test gallery",
artworkIds: [30, 10, 20],
},
{
_id: emptyGalleryId,
userId: ownerId,
title: "Empty gallery",
artworkIds: [],
},
]);

await server.start();
}, 15000);

afterAll(async () => {
try {
await server.stop();
} finally {
try {
await database.dropDatabase();
} finally {
await client.close();
}
}
}, 15000);

const galleryQuery = `
query GetGallery($id: ID!, $limit: Int!, $offset: Int!) {
gallery(id: $id, limit: $limit, offset: $offset) {
id
title
totalCount
artworks {
id
title
}
}
}
`;

describe("Gallery API", () => {
it("returns the owner's gallery with artworks ordered by ID", async () => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: galleryId.toString(),
limit: 10,
offset: 0,
},
},
{
contextValue: { userId: ownerId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.gallery).toEqual({
id: galleryId.toString(),
title: "Test gallery",
totalCount: 3,
artworks: [
{ id: 10, title: "First artwork" },
{ id: 20, title: "Second artwork" },
{ id: 30, title: "Third artwork" },
],
});
});

it("paginates gallery artworks while keeping the total count", async () => {
const pages = [];

for (const offset of [0, 2]) {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: galleryId.toString(),
limit: 2,
offset,
},
},
{
contextValue: { userId: ownerId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.errors).toBeUndefined();
pages.push(response.body.singleResult.data?.gallery);
}

expect(pages).toEqual([
{
id: galleryId.toString(),
title: "Test gallery",
totalCount: 3,
artworks: [
{ id: 10, title: "First artwork" },
{ id: 20, title: "Second artwork" },
],
},
{
id: galleryId.toString(),
title: "Test gallery",
totalCount: 3,
artworks: [{ id: 30, title: "Third artwork" }],
},
]);
});

it("does not expose another user's gallery", async () => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: galleryId.toString(),
limit: 10,
offset: 0,
},
},
{
contextValue: { userId: otherUserId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.gallery).toBeNull();
});

it("requires an authenticated user", async () => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: galleryId.toString(),
limit: 10,
offset: 0,
},
},
{
contextValue: { userId: null },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.data?.gallery).toBeNull();
expect(response.body.singleResult.errors).toEqual([
expect.objectContaining({
message: "You must be logged in",
}),
]);
});

it("returns an empty list and zero count for an empty gallery", async () => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: emptyGalleryId.toString(),
limit: 10,
offset: 0,
},
},
{
contextValue: { userId: ownerId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.gallery).toEqual({
id: emptyGalleryId.toString(),
title: "Empty gallery",
totalCount: 0,
artworks: [],
});
});

it.each([
["a nonexistent gallery", new ObjectId().toString()],
["an invalid gallery ID", "invalid-id"],
])("returns null for %s", async (_description, id) => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id,
limit: 10,
offset: 0,
},
},
{
contextValue: { userId: ownerId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.gallery).toBeNull();
});

it.each([
{ limit: 0, offset: 0 },
{ limit: 101, offset: 0 },
{ limit: 2, offset: -1 },
])(
"rejects invalid pagination: limit=$limit, offset=$offset",
async ({ limit, offset }) => {
const response = await server.executeOperation(
{
query: galleryQuery,
variables: {
id: galleryId.toString(),
limit,
offset,
},
},
{
contextValue: { userId: ownerId.toString() },
},
);

if (response.body.kind !== "single") {
throw new Error("Expected a single GraphQL response");
}

expect(response.body.singleResult.data?.gallery).toBeNull();
expect(response.body.singleResult.errors).toEqual([
expect.objectContaining({
message: "Invalid pagination parameters",
}),
]);
},
);
});
Loading