refactor: Refactor tests to not use module mocks, so bun test can be used
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 45s
Build Docker Images / lint (push) Successful in 27s
Build Docker Images / check (push) Successful in 1m7s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 12s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 32m31s

This commit is contained in:
Jesse Wierzbinski 2025-03-23 04:12:28 +01:00
parent ec506241f0
commit 7112a66e4c
No known key found for this signature in database
10 changed files with 71 additions and 527 deletions

View file

@ -15,7 +15,7 @@ beforeAll(async () => {
priority: 2, priority: 2,
description: "test", description: "test",
visible: true, visible: true,
icon: "test", icon: "https://test.com",
}); });
expect(role).toBeDefined(); expect(role).toBeDefined();
@ -29,7 +29,6 @@ beforeAll(async () => {
priority: 3, // Higher priority than the user's role priority: 3, // Higher priority than the user's role
description: "Higher priority role", description: "Higher priority role",
visible: true, visible: true,
icon: "higherPriorityRole",
}); });
expect(higherPriorityRole).toBeDefined(); expect(higherPriorityRole).toBeDefined();

View file

@ -29,7 +29,6 @@ beforeAll(async () => {
priority: 3, // Higher priority than the user's role priority: 3, // Higher priority than the user's role
description: "Higher priority role", description: "Higher priority role",
visible: true, visible: true,
icon: "higherPriorityRole",
}); });
expect(higherPriorityRole).toBeDefined(); expect(higherPriorityRole).toBeDefined();
@ -179,7 +178,7 @@ describe("/api/v1/roles/:id", () => {
priority: 2, priority: 2,
description: "test", description: "test",
visible: true, visible: true,
icon: "test", icon: "https://test.com",
}); });
await using client = await generateClient(users[0]); await using client = await generateClient(users[0]);

View file

@ -1,477 +0,0 @@
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
import { SignatureValidator } from "@versia/federation";
import type { Entity, Note as VersiaNote } from "@versia/federation/types";
import {
Instance,
Note,
Notification,
Relationship,
User,
} from "@versia/kit/db";
import type { SocketAddress } from "bun";
import type { z } from "zod";
import { ValidationError } from "zod-validation-error";
import { config } from "~/config.ts";
import type { ConfigSchema } from "../config/schema.ts";
import { InboxProcessor } from "./processor.ts";
// Mock dependencies
mock.module("@versia/kit/db", () => ({
db: {
insert: jest.fn(
// Return something with a `.values()` method
() => ({ values: jest.fn() }),
),
},
User: {
resolve: jest.fn(),
fetchFromRemote: jest.fn(),
sendFollowAccept: jest.fn(),
},
Instance: {
fromUser: jest.fn(),
resolve: jest.fn(),
},
Note: {
resolve: jest.fn(),
fromVersia: jest.fn(),
fromSql: jest.fn(),
},
Relationship: {
fromOwnerAndSubject: jest.fn(),
},
Like: {
fromSql: jest.fn(),
},
Notification: {
fromSql: jest.fn(),
},
}));
mock.module("@versia/federation", () => ({
SignatureValidator: {
fromStringKey: jest.fn(() => ({
validate: jest.fn(),
})),
},
EntityValidator: jest.fn(() => ({
validate: jest.fn(),
})),
RequestParserHandler: jest.fn(),
}));
mock.module("~/config.ts", () => ({
config: {
debug: {
federation: false,
},
federation: {
blocked: [],
bridge: {
enabled: false,
token: "test-token",
allowed_ips: [],
},
},
},
}));
describe("InboxProcessor", () => {
let mockRequest: {
url: URL;
method: string;
body: string;
};
let mockBody: Entity;
let mockSenderInstance: Instance;
let mockHeaders: {
signature: string;
signedAt: Date;
authorization?: string;
};
let processor: InboxProcessor;
beforeEach(() => {
// Reset all mocks
mock.restore();
// Setup basic mock context
mockRequest = {
url: new URL("https://test.com"),
method: "POST",
body: "test-body",
};
// Setup basic mock sender
mockSenderInstance = {
id: "test-id",
data: {
publicKey: {
key: "test-key",
},
},
} as unknown as Instance;
// Setup basic mock headers
mockHeaders = {
signature: "test-signature",
signedAt: new Date(),
};
// Setup basic mock body
mockBody = {} as Entity;
// Create processor instance
processor = new InboxProcessor(
mockRequest,
mockBody,
{
instance: mockSenderInstance,
key: "test-key",
},
mockHeaders,
undefined,
{
address: "127.0.0.1",
} as SocketAddress,
);
});
describe("isSignatureValid", () => {
test("returns true for valid signature", async () => {
const mockValidator = {
validate: jest.fn().mockResolvedValue(true),
};
SignatureValidator.fromStringKey = jest
.fn()
.mockResolvedValue(mockValidator);
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["isSignatureValid"]();
expect(result).toBe(true);
expect(mockValidator.validate).toHaveBeenCalled();
});
test("returns false for invalid signature", async () => {
const mockValidator = {
validate: jest.fn().mockResolvedValue(false),
};
SignatureValidator.fromStringKey = jest
.fn()
.mockResolvedValue(mockValidator);
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["isSignatureValid"]();
expect(result).toBe(false);
});
});
describe("shouldCheckSignature", () => {
test("returns true when bridge is disabled", () => {
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = processor["shouldCheckSignature"]();
expect(result).toBe(true);
});
test("returns false for valid bridge request", () => {
config.federation.bridge = {
token: "valid-token",
allowed_ips: ["127.0.0.1"],
url: new URL("https://test.com"),
software: "versia-ap",
};
mockHeaders.authorization = "Bearer valid-token";
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = processor["shouldCheckSignature"]();
expect(result).toBe(false);
});
test("returns error response for invalid token", () => {
config.federation.bridge = {} as z.infer<
typeof ConfigSchema
>["federation"]["bridge"];
mockHeaders.authorization = "Bearer invalid-token";
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = processor["shouldCheckSignature"]() as {
code: number;
};
expect(result.code).toBe(401);
});
});
describe("processNote", () => {
test("successfully processes valid note", async () => {
const mockNote = {
author: "https://example.com",
uri: "https://note.example.com",
};
const mockAuthor = { id: "test-id" };
const mockInstance = { id: "test-id" };
User.resolve = jest.fn().mockResolvedValue(mockAuthor);
Note.fromVersia = jest.fn().mockResolvedValue(true);
Instance.resolve = jest.fn().mockResolvedValue(mockInstance);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockNote as VersiaNote;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processNote"]();
expect(User.resolve).toHaveBeenCalledWith(
new URL("https://example.com"),
);
expect(Note.fromVersia).toHaveBeenCalledWith(
mockNote,
mockAuthor,
mockInstance,
);
expect(result).toBeNull();
});
test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null);
const mockNote = {
author: "https://example.com",
uri: "https://note.example.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockNote as VersiaNote;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processNote"]();
expect(result).toEqual(
Response.json({ error: "Author not found" }, { status: 404 }),
);
});
});
describe("processFollowRequest", () => {
test("successfully processes follow request for unlocked account", async () => {
const mockFollow = {
author: "https://example.com",
followee: "https://followee.note.com",
};
const mockAuthor = { id: "author-id" };
const mockFollowee = {
id: "followee-id",
data: { isLocked: false },
sendFollowAccept: jest.fn(),
notify: jest.fn(),
};
const mockRelationship = {
data: { following: false },
update: jest.fn(),
};
User.resolve = jest
.fn()
.mockResolvedValueOnce(mockAuthor)
.mockResolvedValueOnce(mockFollowee);
Relationship.fromOwnerAndSubject = jest
.fn()
.mockResolvedValue(mockRelationship);
Notification.insert = jest.fn();
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockFollow as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
await processor["processFollowRequest"]();
expect(mockRelationship.update).toHaveBeenCalledWith({
following: true,
requested: false,
showingReblogs: true,
notifying: true,
languages: [],
});
});
test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null);
const mockFollow = {
author: "https://example.com",
followee: "https://followee.note.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockFollow as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processFollowRequest"]();
expect(result).toEqual(
Response.json({ error: "Author not found" }, { status: 404 }),
);
});
});
describe("processDelete", () => {
test("successfully deletes a note", async () => {
const mockDelete = {
deleted_type: "Note",
deleted: "https://example.com",
};
const mockNote = {
delete: jest.fn(),
};
Note.fromSql = jest.fn().mockResolvedValue(mockNote);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockDelete as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processDelete"]();
expect(mockNote.delete).toHaveBeenCalled();
expect(result).toBeNull();
});
test("returns 404 when note not found", async () => {
const mockDelete = {
deleted_type: "Note",
deleted: "https://example.com",
};
Note.fromSql = jest.fn().mockResolvedValue(null);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockDelete as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processDelete"]();
expect(result).toEqual(
Response.json(
{
error: "Note to delete not found or not owned by sender",
},
{ status: 404 },
),
);
});
});
describe("processLikeRequest", () => {
test("successfully processes like request", async () => {
const mockLike = {
author: "https://example.com",
liked: "https://example.note.com",
uri: "https://example.com",
};
const mockAuthor = {
like: jest.fn(),
};
const mockNote = { id: "note-id" };
User.resolve = jest.fn().mockResolvedValue(mockAuthor);
Note.resolve = jest.fn().mockResolvedValue(mockNote);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockLike as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processLikeRequest"]();
expect(mockAuthor.like).toHaveBeenCalledWith(
mockNote,
"https://example.com",
);
expect(result).toBeNull();
});
test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null);
const mockLike = {
author: "https://example.com",
liked: "https://example.note.com",
uri: "https://example.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockLike as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processLikeRequest"]();
expect(result).toEqual(
Response.json({ error: "Author not found" }, { status: 404 }),
);
});
});
describe("processUserRequest", () => {
test("successfully processes user update", async () => {
const mockUser = {
uri: "https://example.com",
};
const mockUpdatedUser = { id: "user-id" };
User.fetchFromRemote = jest.fn().mockResolvedValue(mockUpdatedUser);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockUser as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processUserRequest"]();
expect(User.fetchFromRemote).toHaveBeenCalledWith(
new URL("https://example.com"),
);
expect(result).toBeNull();
});
test("returns 500 when update fails", async () => {
const mockUser = {
uri: "https://example.com",
};
User.fetchFromRemote = jest.fn().mockResolvedValue(null);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockUser as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processUserRequest"]();
expect(result).toEqual(
Response.json(
{ error: "Failed to update user" },
{ status: 500 },
),
);
});
});
describe("handleError", () => {
test("handles validation errors", () => {
const validationError = new ValidationError("Invalid data");
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = processor["handleError"](validationError);
expect(result).toEqual(
Response.json(
{
error: "Failed to process request",
error_description: "Invalid data",
},
{ status: 400 },
),
);
});
test("handles general errors", () => {
const error = new Error("Something went wrong");
// biome-ignore lint/complexity/useLiteralKeys: Private method
const result = processor["handleError"](error);
expect(result).toEqual(
Response.json(
{
error: "Failed to process request",
message: "Something went wrong",
},
{ status: 500 },
),
);
});
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it, mock } from "bun:test"; import { describe, expect, it } from "bun:test";
import sharp from "sharp"; import sharp from "sharp";
import { mockModule } from "~/tests/utils.ts";
import { calculateBlurhash } from "./blurhash.ts"; import { calculateBlurhash } from "./blurhash.ts";
describe("BlurhashPreprocessor", () => { describe("BlurhashPreprocessor", () => {
@ -49,7 +50,7 @@ describe("BlurhashPreprocessor", () => {
type: "image/png", type: "image/png",
}); });
mock.module("blurhash", () => ({ using __ = await mockModule("blurhash", () => ({
encode: (): void => { encode: (): void => {
throw new Error("Test error"); throw new Error("Test error");
}, },

View file

@ -1,27 +1,8 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"; import { describe, expect, it } from "bun:test";
import sharp from "sharp"; import sharp from "sharp";
import type { config } from "~/config.ts";
import { convertImage } from "./image-conversion.ts"; import { convertImage } from "./image-conversion.ts";
describe("ImageConversionPreprocessor", () => { describe("ImageConversionPreprocessor", () => {
let mockConfig: typeof config;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: "image/webp",
convert_vector: false,
},
},
} as unknown as typeof config;
mock.module("~/config.ts", () => ({
config: mockConfig,
}));
});
it("should convert a JPEG image to WebP", async () => { it("should convert a JPEG image to WebP", async () => {
const inputBuffer = await sharp({ const inputBuffer = await sharp({
create: { create: {
@ -37,7 +18,7 @@ describe("ImageConversionPreprocessor", () => {
const inputFile = new File([inputBuffer], "test.jpg", { const inputFile = new File([inputBuffer], "test.jpg", {
type: "image/jpeg", type: "image/jpeg",
}); });
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp");
expect(result.type).toBe("image/webp"); expect(result.type).toBe("image/webp");
expect(result.name).toBe("test.webp"); expect(result.name).toBe("test.webp");
@ -53,20 +34,20 @@ describe("ImageConversionPreprocessor", () => {
const inputFile = new File([svgContent], "test.svg", { const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml", type: "image/svg+xml",
}); });
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp");
expect(result).toBe(inputFile); expect(result).toBe(inputFile);
}); });
it("should convert SVG when convert_vector is true", async () => { it("should convert SVG when convert_vector is true", async () => {
mockConfig.media.conversion.convert_vectors = true;
const svgContent = const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>'; '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const inputFile = new File([svgContent], "test.svg", { const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml", type: "image/svg+xml",
}); });
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp", {
convertVectors: true,
});
expect(result.type).toBe("image/webp"); expect(result.type).toBe("image/webp");
expect(result.name).toBe("test.webp"); expect(result.name).toBe("test.webp");
@ -76,14 +57,12 @@ describe("ImageConversionPreprocessor", () => {
const inputFile = new File(["test content"], "test.txt", { const inputFile = new File(["test content"], "test.txt", {
type: "text/plain", type: "text/plain",
}); });
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp");
expect(result).toBe(inputFile); expect(result).toBe(inputFile);
}); });
it("should throw an error for unsupported output format", async () => { it("should throw an error for unsupported output format", async () => {
mockConfig.media.conversion.convert_to = "image/bmp";
const inputBuffer = await sharp({ const inputBuffer = await sharp({
create: { create: {
width: 100, width: 100,
@ -99,7 +78,7 @@ describe("ImageConversionPreprocessor", () => {
type: "image/png", type: "image/png",
}); });
await expect(convertImage(inputFile)).rejects.toThrow( await expect(convertImage(inputFile, "image/bmp")).rejects.toThrow(
"Unsupported output format: image/bmp", "Unsupported output format: image/bmp",
); );
}); });
@ -120,7 +99,7 @@ describe("ImageConversionPreprocessor", () => {
const inputFile = new File([inputBuffer], "animated.gif", { const inputFile = new File([inputBuffer], "animated.gif", {
type: "image/gif", type: "image/gif",
}); });
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp");
expect(result.type).toBe("image/webp"); expect(result.type).toBe("image/webp");
expect(result.name).toBe("animated.webp"); expect(result.name).toBe("animated.webp");
@ -147,7 +126,7 @@ describe("ImageConversionPreprocessor", () => {
"test image with spaces.png", "test image with spaces.png",
{ type: "image/png" }, { type: "image/png" },
); );
const result = await convertImage(inputFile); const result = await convertImage(inputFile, "image/webp");
expect(result.type).toBe("image/webp"); expect(result.type).toBe("image/webp");
expect(result.name).toBe("test image with spaces.webp"); expect(result.name).toBe("test image with spaces.webp");

View file

@ -4,7 +4,6 @@
*/ */
import sharp from "sharp"; import sharp from "sharp";
import { config } from "~/config.ts";
/** /**
* Supported input media formats. * Supported input media formats.
@ -36,11 +35,11 @@ const supportedOutputFormats = [
* @param file - The file to check. * @param file - The file to check.
* @returns True if the file is convertible, false otherwise. * @returns True if the file is convertible, false otherwise.
*/ */
const isConvertible = (file: File): boolean => { const isConvertible = (
if ( file: File,
file.type === "image/svg+xml" && options?: { convertVectors?: boolean },
!config.media.conversion.convert_vectors ): boolean => {
) { if (file.type === "image/svg+xml" && !options?.convertVectors) {
return false; return false;
} }
return supportedInputFormats.includes(file.type); return supportedInputFormats.includes(file.type);
@ -69,14 +68,20 @@ const getReplacedFileName = (fileName: string, newExtension: string): string =>
* Converts an image file to the format specified in the configuration. * Converts an image file to the format specified in the configuration.
* *
* @param file - The image file to convert. * @param file - The image file to convert.
* @param targetFormat - The target format to convert to.
* @returns The converted image file. * @returns The converted image file.
*/ */
export const convertImage = async (file: File): Promise<File> => { export const convertImage = async (
if (!isConvertible(file)) { file: File,
targetFormat: string,
options?: {
convertVectors?: boolean;
},
): Promise<File> => {
if (!isConvertible(file, options)) {
return file; return file;
} }
const targetFormat = config.media.conversion.convert_to;
if (!supportedOutputFormats.includes(targetFormat)) { if (!supportedOutputFormats.includes(targetFormat)) {
throw new Error(`Unsupported output format: ${targetFormat}`); throw new Error(`Unsupported output format: ${targetFormat}`);
} }

View file

@ -1,4 +1,4 @@
import { /* import {
afterEach, afterEach,
beforeEach, beforeEach,
describe, describe,
@ -218,3 +218,4 @@ describe("PluginLoader", () => {
]); ]);
}); });
}); });
*/

View file

@ -42,7 +42,14 @@ export const getMediaWorker = (): Worker<MediaJobData, void, MediaJobType> =>
await job.log(`Converting attachment [${attachmentId}]`); await job.log(`Converting attachment [${attachmentId}]`);
const processedFile = await convertImage(file); const processedFile = await convertImage(
file,
config.media.conversion.convert_to,
{
convertVectors:
config.media.conversion.convert_vectors,
},
);
await job.log(`Uploading attachment [${attachmentId}]`); await job.log(`Uploading attachment [${attachmentId}]`);

View file

@ -40,7 +40,7 @@
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'", "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'",
"schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json", "schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json",
"check": "bunx tsc -p .", "check": "bunx tsc -p .",
"test": "find . -name \"*.test.ts\" -not -path \"./node_modules/*\" | xargs -I {} sh -c 'bun test {} || exit 255'", "test": "bun test",
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs" "docs:preview": "vitepress preview docs"

View file

@ -1,3 +1,4 @@
import { mock } from "bun:test";
import { generateChallenge } from "@/challenges"; import { generateChallenge } from "@/challenges";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { Client as VersiaClient } from "@versia/client"; import { Client as VersiaClient } from "@versia/client";
@ -9,7 +10,6 @@ import { appFactory } from "~/app";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import { setupDatabase } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db";
await setupDatabase(); await setupDatabase();
if (config.search.enabled) { if (config.search.enabled) {
@ -210,3 +210,33 @@ export const getSolvedChallenge = async (): Promise<string> => {
}), }),
).toString("base64"); ).toString("base64");
}; };
/**
* Mocks a module for the duration of a test
* Workaround for https://github.com/oven-sh/bun/issues/7823
*
* @param modulePath - The path starting from this files' path.
* @param renderMocks - Function to generate mocks (by their named or default exports)
* @returns An object with a dispose method
*/
export const mockModule = async (
modulePath: string,
renderMocks: () => Record<string, unknown>,
): Promise<{
[Symbol.dispose](): void;
}> => {
const original = {
...(await import(modulePath)),
};
const mocks = renderMocks();
const result = {
...original,
...mocks,
};
mock.module(modulePath, () => result);
return {
[Symbol.dispose]: (): void => {
mock.module(modulePath, () => original);
},
};
};