test: support server-side unit testing (#2485)

This commit is contained in:
Michael Thomas
2026-03-12 09:39:41 -04:00
committed by GitHub
parent 40edaea43f
commit 8563362588
17 changed files with 2355 additions and 68 deletions

View File

@@ -127,6 +127,51 @@ jobs:
- name: Build
run: pnpm build
unit-test:
name: Unit Tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
permissions:
checks: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Pnpm Setup
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
env:
CI: true
run: pnpm install
- name: Run tests
env:
CI: true
run: pnpm test
- name: Publish test report
uses: mikepenz/action-junit-report@74626db7353a25a20a72816467ebf035f674c5f8 # v6.2.0
if: success() || failure() # always run even if the previous step fails
with:
report_paths: 'report.xml'
build:
name: Build (per-arch, native runners)
if: github.ref == 'refs/heads/develop'

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
# testing
/coverage
lcov.info
# next.js
/.next/

View File

@@ -16,6 +16,7 @@
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"firsttris.vscode-jest-runner"
]
}

View File

@@ -23,5 +23,12 @@
"i18n-ally.localesPaths": [
"src/i18n/locale"
],
"yaml.format.singleQuote": true
"yaml.format.singleQuote": true,
"jestrunner.enableTestExplorer": true,
"jestrunner.defaultTestPatterns": [
"server/**/*.{test,spec}.?(c|m)[jt]s?(x)",
],
"jestrunner.nodeTestCommand": "pnpm test",
"jestrunner.changeDirectoryToWorkspaceRoot": true,
"jestrunner.projectPath": "."
}

View File

@@ -12,6 +12,7 @@
"build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"test": "node server/test/index.mts",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
@@ -137,6 +138,7 @@
"@types/react-transition-group": "4.4.12",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.7.1",
"@types/supertest": "^6.0.3",
"@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.10",
"@types/web-push": "3.6.4",
@@ -147,6 +149,7 @@
"@typescript-eslint/parser": "7.18.0",
"autoprefixer": "^10.4.23",
"baseline-browser-mapping": "^2.8.32",
"commander": "^14.0.3",
"commitizen": "4.3.1",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
@@ -168,6 +171,7 @@
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"prettier-plugin-tailwindcss": "0.6.14",
"supertest": "^7.2.2",
"tailwindcss": "3.4.19",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",

1598
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,17 @@ function buildSslConfig(): TlsOptions | undefined {
};
}
const testConfig: DataSourceOptions = {
type: 'sqlite',
database: ':memory:',
synchronize: true,
dropSchema: true,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/sqlite/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
const devConfig: DataSourceOptions = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
@@ -105,7 +116,9 @@ const postgresProdConfig: DataSourceOptions = {
export const isPgsql = process.env.DB_TYPE === 'postgres';
function getDataSource(): DataSourceOptions {
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'test') {
return testConfig;
} else if (process.env.NODE_ENV === 'production') {
return isPgsql ? postgresProdConfig : prodConfig;
} else {
return isPgsql ? postgresDevConfig : devConfig;

View File

@@ -79,7 +79,7 @@ export class User {
@Column({ nullable: true, select: false })
public resetPasswordGuid?: string;
@Column({ type: 'date', nullable: true })
@DbAwareColumn({ type: 'datetime', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })

View File

@@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RecoveryLinkExpirationDateTime1771337333450 implements MigrationInterface {
name = 'RecoveryLinkExpirationDateTime1771337333450';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE TIMESTAMP WITH TIME ZONE`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE date USING ("recoveryLinkExpirationDate"::date)`
);
}
}

View File

@@ -0,0 +1,27 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RecoveryLinkExpirationDateTime1771337037917 implements MigrationInterface {
name = 'RecoveryLinkExpirationDateTime1771337037917';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

397
server/routes/auth.test.ts Normal file
View File

@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
import { getSettings } from '@server/lib/settings';
import { checkUser } from '@server/middleware/auth';
import { setupTestDb } from '@server/test/db';
import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import authRoutes from './auth';
const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => {
return undefined;
}).mock;
let app: Express;
function createApp() {
const app = express();
app.use(express.json());
app.use(
session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
);
app.use(checkUser);
app.use('/auth', authRoutes);
// Error handler matching how next({ status, message }) calls are handled
app.use(
(
err: { status?: number; message?: string },
_req: express.Request,
res: express.Response,
// We must provide a next function for the function signature here even though its not used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: express.NextFunction
) => {
res
.status(err.status ?? 500)
.json({ status: err.status ?? 500, message: err.message });
}
);
return app;
}
before(async () => {
app = createApp();
});
setupTestDb();
/** Create a supertest agent that is logged in as the given user. */
async function authenticatedAgent(email: string, password: string) {
const agent = request.agent(app);
const settings = getSettings();
settings.main.localLogin = true;
const res = await agent.post('/auth/local').send({ email, password });
assert.strictEqual(res.status, 200);
return agent;
}
describe('GET /auth/me', () => {
it('returns 403 when not authenticated', async () => {
const res = await request(app).get('/auth/me');
assert.strictEqual(res.status, 403);
});
it('returns the authenticated user', async () => {
const agent = await authenticatedAgent('admin@seerr.dev', 'test1234');
const res = await agent.get('/auth/me');
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
assert.strictEqual(res.body.displayName, 'admin');
});
it('includes userEmailRequired warning when email is required but invalid', async () => {
const settings = getSettings();
settings.notifications.agents.email.options.userEmailRequired = true;
// Change the user's email to something invalid
const userRepo = getRepository(User);
const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});
user.email = 'not-an-email';
await userRepo.save(user);
// Log in with the changed email
const agent = request.agent(app);
settings.main.localLogin = true;
const loginRes = await agent
.post('/auth/local')
.send({ email: 'not-an-email', password: 'test1234' });
assert.strictEqual(loginRes.status, 200);
const res = await agent.get('/auth/me');
assert.strictEqual(res.status, 200);
assert.ok(res.body.warnings.includes('userEmailRequired'));
settings.notifications.agents.email.options.userEmailRequired = false;
});
});
describe('POST /auth/local', () => {
beforeEach(() => {
const settings = getSettings();
settings.main.localLogin = true;
});
it('returns 200 and user data on valid credentials', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
// filter() strips sensitive fields like password
assert.ok(!('password' in res.body));
});
it('returns 403 on wrong password', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'wrongpassword' });
assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.message, 'Access denied.');
});
it('returns 403 for nonexistent user', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'nobody@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.message, 'Access denied.');
});
it('returns 500 when local login is disabled', async () => {
const settings = getSettings();
settings.main.localLogin = false;
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.error, 'Password sign-in is disabled.');
});
it('returns 500 when email is missing', async () => {
const res = await request(app)
.post('/auth/local')
.send({ password: 'test1234' });
assert.strictEqual(res.status, 500);
assert.match(res.body.error, /email address and a password/);
});
it('returns 500 when password is missing', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev' });
assert.strictEqual(res.status, 500);
assert.match(res.body.error, /email address and a password/);
});
it('is case-insensitive for email', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'Admin@Seerr.Dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
});
it('allows the non-admin user to log in', async () => {
const res = await request(app)
.post('/auth/local')
.send({ email: 'friend@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
});
it('sets a session on successful login', async () => {
const agent = request.agent(app);
await agent
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
// Session should persist — /me should succeed
const meRes = await agent.get('/auth/me');
assert.strictEqual(meRes.status, 200);
});
});
describe('POST /auth/logout', () => {
it('returns 200 when not logged in', async () => {
const res = await request(app).post('/auth/logout');
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
});
it('destroys session and returns 200 when logged in', async () => {
const agent = await authenticatedAgent('admin@seerr.dev', 'test1234');
// Verify session is active
const meBeforeRes = await agent.get('/auth/me');
assert.strictEqual(meBeforeRes.status, 200);
const logoutRes = await agent.post('/auth/logout');
assert.strictEqual(logoutRes.status, 200);
assert.strictEqual(logoutRes.body.status, 'ok');
// Session should be invalidated — /me should fail
const meAfterRes = await agent.get('/auth/me');
assert.strictEqual(meAfterRes.status, 403);
});
});
describe('POST /auth/reset-password', () => {
beforeEach(() => {
emailMock.resetCalls();
});
it('returns 200 for a valid email', async () => {
const res = await request(app)
.post('/auth/reset-password')
.send({ email: 'admin@seerr.dev' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
assert.strictEqual(emailMock.callCount(), 1);
});
it('returns 200 for nonexistent email (does not reveal user existence)', async () => {
const res = await request(app)
.post('/auth/reset-password')
.send({ email: 'nonexistent@seerr.dev' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
assert.strictEqual(emailMock.callCount(), 0);
});
it('returns 500 when email is missing', async () => {
const res = await request(app).post('/auth/reset-password').send({});
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Email address required.');
assert.strictEqual(emailMock.callCount(), 0);
});
it('sets a resetPasswordGuid on the user', async () => {
await request(app)
.post('/auth/reset-password')
.send({ email: 'admin@seerr.dev' });
const userRepo = getRepository(User);
const user = await userRepo
.createQueryBuilder('user')
.addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate'])
.where('user.email = :email', { email: 'admin@seerr.dev' })
.getOneOrFail();
assert.notStrictEqual(user.resetPasswordGuid, undefined);
assert.notStrictEqual(user.resetPasswordGuid, null);
assert.notStrictEqual(user.recoveryLinkExpirationDate, undefined);
assert.strictEqual(emailMock.callCount(), 1);
});
});
describe('POST /auth/reset-password/:guid', () => {
/** Trigger a password reset and return the guid. */
async function getResetGuid(email: string): Promise<string> {
await request(app).post('/auth/reset-password').send({ email });
const userRepo = getRepository(User);
const user = await userRepo
.createQueryBuilder('user')
.addSelect('user.resetPasswordGuid')
.where('user.email = :email', { email })
.getOneOrFail();
return user.resetPasswordGuid!;
}
it('resets password with a valid guid and password', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.status, 'ok');
// Old password no longer works
const oldLogin = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'test1234' });
assert.strictEqual(oldLogin.status, 403);
// New password works
const newLogin = await request(app)
.post('/auth/local')
.send({ email: 'admin@seerr.dev', password: 'newpassword123' });
assert.strictEqual(newLogin.status, 200);
});
it('returns 500 for an invalid guid', async () => {
const res = await request(app)
.post('/auth/reset-password/invalid-guid-here')
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Invalid password reset link.');
});
it('returns 500 when password is too short', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'short' });
assert.strictEqual(res.status, 500);
assert.strictEqual(
res.body.message,
'Password must be at least 8 characters long.'
);
});
it('returns 500 when password is missing', async () => {
const guid = await getResetGuid('admin@seerr.dev');
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({});
assert.strictEqual(res.status, 500);
assert.strictEqual(
res.body.message,
'Password must be at least 8 characters long.'
);
});
it('returns 500 for an expired recovery link', async () => {
const guid = await getResetGuid('admin@seerr.dev');
// Expire the link
const userRepo = getRepository(User);
const user = await userRepo.findOneOrFail({
where: { email: 'admin@seerr.dev' },
});
user.recoveryLinkExpirationDate = new Date('2020-01-01');
await userRepo.save(user);
const res = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(res.status, 500);
assert.strictEqual(res.body.message, 'Invalid password reset link.');
});
it('cannot reuse a guid after successful reset', async () => {
const guid = await getResetGuid('admin@seerr.dev');
// First reset succeeds
const first = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'newpassword123' });
assert.strictEqual(first.status, 200);
// Second reset with same guid fails (recoveryLinkExpirationDate was cleared)
const second = await request(app)
.post(`/auth/reset-password/${guid}`)
.send({ password: 'anotherpassword' });
assert.strictEqual(second.status, 500);
});
});

View File

@@ -738,7 +738,7 @@ authRoutes.post('/reset-password', async (req, res, next) => {
if (user) {
await user.resetPassword();
userRepository.save(user);
await userRepository.save(user);
logger.info('Successfully sent password reset link', {
label: 'API',
ip: req.ip,
@@ -803,7 +803,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
}
user.recoveryLinkExpirationDate = null;
await user.setPassword(req.body.password);
userRepository.save(user);
await userRepository.save(user);
logger.info('Successfully reset password', {
label: 'API',
ip: req.ip,

View File

@@ -1,8 +1,5 @@
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { seedTestDb } from '@server/utils/seedTestDb';
import { copyFileSync } from 'fs';
import gravatarUrl from 'gravatar-url';
import path from 'path';
const prepareDb = async () => {
@@ -12,61 +9,10 @@ const prepareDb = async () => {
path.join(__dirname, '../../config/settings.json')
);
// Connect to DB and seed test data
const dbConnection = await dataSource.initialize();
if (process.env.PRESERVE_DB !== 'true') {
await dbConnection.dropDatabase();
}
// Run migrations in production
if (process.env.WITH_MIGRATIONS === 'true') {
await dbConnection.runMigrations();
} else {
await dbConnection.synchronize();
}
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexId: true },
where: { id: 1 },
await seedTestDb({
preserveDb: process.env.PRESERVE_DB === 'true',
withMigrations: process.env.WITH_MIGRATIONS === 'true',
});
// Create the admin user
const user =
(await userRepository.findOne({
where: { email: 'admin@seerr.dev' },
})) ?? new User();
user.plexId = admin?.plexId ?? 1;
user.plexToken = '1234';
user.plexUsername = 'admin';
user.username = 'admin';
user.email = 'admin@seerr.dev';
user.userType = UserType.PLEX;
await user.setPassword('test1234');
user.permissions = 2;
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
await userRepository.save(user);
// Create the other user
const otherUser =
(await userRepository.findOne({
where: { email: 'friend@seerr.dev' },
})) ?? new User();
otherUser.plexId = admin?.plexId ?? 1;
otherUser.plexToken = '1234';
otherUser.plexUsername = 'friend';
otherUser.username = 'friend';
otherUser.email = 'friend@seerr.dev';
otherUser.userType = UserType.PLEX;
await otherUser.setPassword('test1234');
otherUser.permissions = 32;
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
default: 'mm',
size: 200,
});
await userRepository.save(otherUser);
};
prepareDb();

11
server/test/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { resetTestDb, seedTestDb } from '@server/utils/seedTestDb';
import { before, beforeEach } from 'node:test';
export function setupTestDb() {
before(async () => {
await seedTestDb();
});
beforeEach(async () => {
await resetTestDb();
});
}

122
server/test/index.mts Normal file
View File

@@ -0,0 +1,122 @@
// Runs unit tests using the `node:test` runner.
import { Command, Option } from 'commander';
import { createWriteStream } from 'node:fs';
import { glob } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { run } from 'node:test';
import * as reporters from 'node:test/reporters';
import { fileURLToPath } from 'node:url';
const resolveImport = (specifier: string) =>
fileURLToPath(import.meta.resolve(specifier));
const BASE_DIR = join(import.meta.dirname, '../..');
const program = new Command();
program
.name('test')
.argument('[file...]', 'Test file(s) to run (default: all)')
.option(
'-m, --test-name-pattern <pattern>',
'Run tests matching the given pattern',
(v, acc: string[]) => [...acc, v],
[] as string[]
)
.option(
'--test-reporter <reporter>',
'Test reporter to use (repeatable)',
(v, acc: string[]) => [...acc, v],
[] as string[]
)
.option(
'--test-reporter-destination <dest>',
'Test reporter destination: stdout, stderr, or a file path (repeatable)',
(v, acc: string[]) => [...acc, v],
[] as string[]
)
.option(
'--coverage, --experimental-test-coverage',
'Enable code coverage collection'
)
// ignore additional options passed by vscode test runner
.addOption(new Option('--test').hideHelp())
.parse();
const positionals: string[] = program.args;
const opts = program.opts<{
testNamePattern: string[];
testReporter: string[];
testReporterDestination: string[];
experimentalTestCoverage: boolean;
}>();
let files: string[];
if (positionals.length > 0) {
files = positionals.map((f) => resolve(f));
} else {
files = [];
for await (const entry of glob(join(BASE_DIR, 'server/**/*.test.ts'))) {
files.push(resolve(entry));
}
files.sort();
}
// @ts-ignore
process.env.NODE_ENV = 'test';
// configure ts
process.env.TS_NODE_PROJECT = resolveImport('../tsconfig.json');
process.env.TS_NODE_FILES = 'true';
const stream = run({
files,
execArgv: [
'--experimental-test-module-mocks',
'-r',
'ts-node/register',
'-r',
'tsconfig-paths/register',
'-r',
resolveImport('./setup.ts'),
],
coverage: opts.experimentalTestCoverage,
coverageExcludeGlobs: [
join(BASE_DIR, 'server/test/**'),
join(BASE_DIR, 'server/migration/**'),
],
testNamePatterns: opts.testNamePattern,
});
// In CI, write a JUnit report to a file for use by GitHub
if (process.env.CI) {
const reportStream = createWriteStream(join(BASE_DIR, 'report.xml'));
stream.compose(reporters.junit).pipe(reportStream);
}
if (opts.testReporter.length > 0) {
for (let i = 0; i < opts.testReporter.length; i++) {
const reporterName = opts.testReporter[i];
// check built-in reporters, otherwise import
const reporter =
reporterName in reporters
? reporters[reporterName as keyof typeof reporters]
: await import(reporterName).then((m) => m.default);
if (reporter == null) {
console.error('Invalid test reporter: ', reporterName);
process.exit(1);
}
const destArg = opts.testReporterDestination[i];
const dest =
destArg === 'stdout' || destArg == null
? process.stdout
: destArg === 'stderr'
? process.stderr
: createWriteStream(destArg);
stream.compose(reporter).pipe(dest);
}
} else {
stream.compose(reporters.spec).pipe(process.stdout);
}

10
server/test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import logger from '@server/logger';
import { after, before } from 'node:test';
before(() => {
if (process.env.VERBOSE != 'true') logger.silent = true;
});
after(() => {
if (process.env.VERBOSE != 'true') logger.silent = false;
});

View File

@@ -0,0 +1,96 @@
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import gravatarUrl from 'gravatar-url';
export interface SeedDbOptions {
/** If true, preserves existing data instead of dropping the database */
preserveDb?: boolean;
/** If true, runs migrations instead of synchronizing schema */
withMigrations?: boolean;
}
// Precomputed bcrypt hash of 'test1234'. We precompute this to avoid
// having to hash the password every time we seed the database.
const TEST_USER_PASSWORD_HASH =
'$2b$12$Z5V2P5HZgmx4/AnWFMZN1.aD5AM1NucNi.mhNTSQ9oVtmdzu7Le/a';
/**
* Seeds test users into the database.
* Assumes the database schema is already set up.
*/
async function seedTestUsers(): Promise<void> {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexId: true },
where: { id: 1 },
});
// Create the admin user
const user =
(await userRepository.findOne({
where: { email: 'admin@seerr.dev' },
})) ?? new User();
user.plexId = admin?.plexId ?? 1;
user.plexToken = '1234';
user.plexUsername = 'admin';
user.username = 'admin';
user.email = 'admin@seerr.dev';
user.userType = UserType.PLEX;
user.password = TEST_USER_PASSWORD_HASH;
user.permissions = 2;
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
await userRepository.save(user);
// Create the other user
const otherUser =
(await userRepository.findOne({
where: { email: 'friend@seerr.dev' },
})) ?? new User();
otherUser.plexId = admin?.plexId ?? 1;
otherUser.plexToken = '1234';
otherUser.plexUsername = 'friend';
otherUser.username = 'friend';
otherUser.email = 'friend@seerr.dev';
otherUser.userType = UserType.PLEX;
otherUser.password = TEST_USER_PASSWORD_HASH;
otherUser.permissions = 32;
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
default: 'mm',
size: 200,
});
await userRepository.save(otherUser);
}
/**
* Initializes the database connection and seeds test users.
* Used by both Cypress tests and Vitest unit tests.
*/
export async function seedTestDb(options: SeedDbOptions = {}): Promise<void> {
const dbConnection = dataSource.isInitialized
? dataSource
: await dataSource.initialize();
if (!options.preserveDb) {
await dbConnection.dropDatabase();
}
if (options.withMigrations) {
await dbConnection.runMigrations();
} else {
await dbConnection.synchronize();
}
await seedTestUsers();
}
/**
* Resets the database to a clean state with seeded test users.
* Used between tests to ensure isolation.
* Assumes DB has been initialized.
*/
export async function resetTestDb(): Promise<void> {
await dataSource.synchronize(true);
await seedTestUsers();
}