feat: add a demo feature (#3017)

This commit is contained in:
Gauthier
2026-09-21 10:11:05 +02:00
committed by GitHub
parent 7afbb2914f
commit 38581ca1c0
13 changed files with 147 additions and 42 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
env: {
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'test1234',
USER_EMAIL: 'friend@seerr.dev',
USER_EMAIL: 'demo@seerr.dev',
USER_PASSWORD: 'test1234',
},
retries: {
+2 -2
View File
@@ -145,7 +145,7 @@ describe('Discover', () => {
requestedBy: {
permissions: 4194336,
id: 18,
email: 'friend@seerr.dev',
email: 'demo@seerr.dev',
plexUsername: null,
username: '',
recoveryLinkExpirationDate: null,
@@ -159,7 +159,7 @@ describe('Discover', () => {
createdAt: '2022-08-17T04:55:28.000Z',
updatedAt: '2022-08-17T04:55:28.000Z',
requestCount: 1,
displayName: 'friend@seerr.dev',
displayName: 'demo@seerr.dev',
},
seasonCount: 0,
},
+1
View File
@@ -3,6 +3,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
unsafeDoNotUseDemo: process.env.UNSAFE_DO_NOT_USE_DEMO,
},
images: {
remotePatterns: [
+1 -1
View File
@@ -46,7 +46,7 @@ async function seedRequester(movieQuotaLimit: number): Promise<User> {
const userRepository = getRepository(User);
const requester = await userRepository.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
requester.movieQuotaLimit = movieQuotaLimit;
+8
View File
@@ -30,6 +30,7 @@ import createCustomProxyAgent, {
setForceIpv4First,
} from '@server/utils/customProxyAgent';
import { isPgsql } from '@server/utils/dbType';
import { initDemoData } from '@server/utils/demoMode';
import { initializeDnsCache } from '@server/utils/dnsCache';
import restartFlag from '@server/utils/restartFlag';
import '@server/utils/userAgent';
@@ -253,6 +254,13 @@ app
};
next();
});
// Init demo mode and catch some API routes
if (process.env.UNSAFE_DO_NOT_USE_DEMO === 'true') {
logger.info('Demo mode enabled, seeding database with demo data');
await initDemoData(server);
}
server.use('/api/v1', routes);
// Do not set cookies so CDNs can cache them
+1 -1
View File
@@ -692,7 +692,7 @@ describe('POST /auth/local', () => {
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' });
.send({ email: 'demo@seerr.dev', password: 'test1234' });
assert.strictEqual(res.status, 200);
assert.ok('id' in res.body);
+9 -9
View File
@@ -109,7 +109,7 @@ describe('POST /issue', () => {
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const agent = await loginAs('[email protected]', 'test1234');
@@ -123,8 +123,8 @@ describe('POST /issue', () => {
});
assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev');
const persisted = await issueRepo.findOneOrFail({
where: { id: res.body.id },
@@ -153,13 +153,13 @@ describe('POST /issue', () => {
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
friend.permissions = Permission.CREATE_ISSUES;
await userRepo.save(friend);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.SUBTITLES,
message: 'Subtitles are missing.',
@@ -168,15 +168,15 @@ describe('POST /issue', () => {
});
assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev');
assert.strictEqual(res.body.createdBy.email, 'demo@seerr.dev');
assert.strictEqual(res.body.comments[0].user.email, 'demo@seerr.dev');
});
it('prevents non-managers from supplying another userId', async () => {
const userRepo = getRepository(User);
const media = await seedMedia();
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const admin = await userRepo.findOneOrFail({
where: { email: '[email protected]' },
@@ -185,7 +185,7 @@ describe('POST /issue', () => {
friend.permissions = Permission.CREATE_ISSUES;
await userRepo.save(friend);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/issue').send({
issueType: IssueType.OTHER,
message: 'Something else is wrong.',
+18 -18
View File
@@ -193,7 +193,7 @@ async function seedRequest(status = MediaRequestStatus.PENDING) {
const requestRepo = getRepository(MediaRequest);
const requestedBy = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const media = await mediaRepo.save(
@@ -226,7 +226,7 @@ describe('DELETE /request/:requestId', () => {
it('allows the owner to delete their own pending request', async () => {
const mediaRequest = await seedRequest();
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 204);
@@ -270,7 +270,7 @@ describe('DELETE /request/:requestId', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 401);
@@ -279,7 +279,7 @@ describe('DELETE /request/:requestId', () => {
it('prevents the owner from deleting an approved request', async () => {
const mediaRequest = await seedRequest(MediaRequestStatus.APPROVED);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.delete(`/request/${mediaRequest.id}`);
assert.strictEqual(res.status, 401);
@@ -349,7 +349,7 @@ describe('PUT /request/:requestId (tv)', () => {
where: { email: '[email protected]' },
});
const otherUser = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const media = await mediaRepo.save(
@@ -798,7 +798,7 @@ describe('POST /request (movie), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -810,7 +810,7 @@ describe('POST /request (movie), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.MOVIE,
mediaId: 88001,
@@ -826,7 +826,7 @@ describe('POST /request (movie), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -838,7 +838,7 @@ describe('POST /request (movie), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.MOVIE,
mediaId: 88002,
@@ -854,7 +854,7 @@ describe('POST /request (movie), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -866,7 +866,7 @@ describe('POST /request (movie), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.MOVIE,
mediaId: 88005,
@@ -884,7 +884,7 @@ describe('POST /request (tv), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -896,7 +896,7 @@ describe('POST /request (tv), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.TV,
mediaId: 88003,
@@ -913,7 +913,7 @@ describe('POST /request (tv), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -925,7 +925,7 @@ describe('POST /request (tv), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.TV,
mediaId: 88004,
@@ -942,7 +942,7 @@ describe('POST /request (tv), override rules', () => {
const userRepo = getRepository(User);
const friend = await userRepo.findOneOrFail({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
});
const overrideRuleRepo = getRepository(OverrideRule);
@@ -954,7 +954,7 @@ describe('POST /request (tv), override rules', () => {
})
);
const agent = await loginAs('friend@seerr.dev', 'test1234');
const agent = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent.post('/request').send({
mediaType: MediaType.TV,
mediaId: 88006,
@@ -1029,7 +1029,7 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => {
});
assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN);
const friend = await loginAs('friend@seerr.dev', 'test1234');
const friend = await loginAs('demo@seerr.dev', 'test1234');
const reRequest = await friend.post('/request').send({
mediaType: MediaType.TV,
mediaId: 99101,
+2 -2
View File
@@ -99,7 +99,7 @@ describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () =>
});
it('links the account when the media server is Jellyfin', async () => {
const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234');
const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234');
const res = await agent
.post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`)
@@ -116,7 +116,7 @@ describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () =>
});
it('returns 403 when the media server is Emby', async () => {
const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234');
const { agent, userId } = await loginAs('demo@seerr.dev', 'test1234');
getSettings().main.mediaServerType = MediaServerType.EMBY;
const res = await agent
+86
View File
@@ -0,0 +1,86 @@
import dataSource from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { seedTestUsers } from '@server/utils/seedTestDb';
import type { Express } from 'express';
import fs from 'fs';
import path from 'path';
async function resetDemoData(): Promise<void> {
try {
// Drop all tables except users and sessions
const entities = dataSource.entityMetadatas;
for (const entity of entities) {
if (['session', 'user'].includes(entity.tableName.toLowerCase())) {
continue;
}
await dataSource.getRepository(entity.name).deleteAll();
}
// Add the default users
await seedTestUsers();
// Remove admin user from the database
await dataSource.getRepository('user').delete({ email: '[email protected]' });
// Re-seed discover sliders
await DiscoverSlider.bootstrapSliders();
} catch (error) {
logger.error('Failed to reset demo data', { error: error.message });
}
}
export async function initDemoData(server: Express): Promise<void> {
// Check that no other data than the demo user exists in the database before seeding demo data
const userCount = await dataSource.getRepository('user').count();
if (
userCount > 1 ||
(userCount === 1 &&
!(await dataSource
.getRepository('user')
.findOneBy({ email: '[email protected]' })))
) {
logger.error('Database is not empty, cannot seed demo data. Aborting.');
process.exit(1);
}
// Retrieve settings from Cypress tests
try {
fs.copyFileSync(
path.join(__dirname, '../../cypress/config/settings.cypress.json'),
path.join(__dirname, '../../config/settings.json')
);
} catch (error) {
logger.error('Failed to copy settings from Cypress tests', { error });
process.exit(1);
}
const settings = getSettings();
await settings.load();
settings.main.mediaServerLogin = false;
await settings.save();
// Seed the database with demo data
await resetDemoData();
// Resets the database every hour
setInterval(resetDemoData, 60 * 60 * 1000);
// Disable password sign-in for non-demo users
server.post('/api/v1/auth/local', async (req, res, next) => {
if (req.body?.email !== '[email protected]') {
return res.status(403).json({ error: 'Password sign-in is disabled.' });
}
next();
});
// Disable password change
server.post('/api/v1/user/:id/settings/password', async (_, res) => {
return res.status(204).send();
});
// Disable test user modification
server.post('/api/v1/user/:id/settings/main', async (_, res) => {
return res.status(200).json({});
});
// Disable unlinking media servers
server.post(
'/api/v1/user/:id/settings/linked-accounts/plex',
async (_, res) => {
return res.status(204).send();
}
);
}
+6 -6
View File
@@ -31,7 +31,7 @@ function assertTestDatabase(operation: string, allowOutsideTest = false): void {
* Seeds test users into the database.
* Assumes the database schema is already set up.
*/
async function seedTestUsers(): Promise<void> {
export async function seedTestUsers(): Promise<void> {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
@@ -58,17 +58,17 @@ async function seedTestUsers(): Promise<void> {
// Create the other user
const otherUser =
(await userRepository.findOne({
where: { email: 'friend@seerr.dev' },
where: { email: 'demo@seerr.dev' },
})) ?? new User();
otherUser.plexId = admin?.plexId ?? 1;
otherUser.plexToken = '1234';
otherUser.plexUsername = 'friend';
otherUser.username = 'friend';
otherUser.email = 'friend@seerr.dev';
otherUser.plexUsername = 'demo';
otherUser.username = 'demo';
otherUser.email = 'demo@seerr.dev';
otherUser.userType = UserType.PLEX;
otherUser.password = TEST_USER_PASSWORD_HASH;
otherUser.permissions = 32;
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
otherUser.avatar = gravatarUrl('demo@seerr.dev', {
default: 'mm',
size: 200,
});
+11 -2
View File
@@ -1,3 +1,4 @@
import Alert from '@app/components/Common/Alert';
import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
@@ -27,6 +28,7 @@ const messages = defineMessages('components.Login', {
signingin: 'Signing In…',
signin: 'Sign In',
forgotpassword: 'Forgot Password?',
demoModeInfo: 'Demo mode is enabled. Use the demo credentials to sign in.',
});
interface LocalLoginProps {
@@ -54,8 +56,9 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
return (
<Formik
initialValues={{
email: '',
password: '',
email:
process.env.unsafeDoNotUseDemo === 'true' ? '[email protected]' : '',
password: process.env.unsafeDoNotUseDemo === 'true' ? 'test1234' : '',
}}
validationSchema={LoginSchema}
validateOnBlur={false}
@@ -89,6 +92,12 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
})}
</h2>
{process.env.unsafeDoNotUseDemo === 'true' && (
<Alert type="info">
{intl.formatMessage(messages.demoModeInfo)}
</Alert>
)}
<div className="mb-4 mt-1">
<div className="form-input-field">
<Field
+1
View File
@@ -256,6 +256,7 @@
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.back": "Go back",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.demoModeInfo": "Demo mode is enabled. Use the demo credentials to sign in.",
"components.Login.description": "Add a valid email address to complete your profile. This will be used for notifications and local sign-in.",
"components.Login.email": "Email Address",
"components.Login.emailAlreadyTaken": "This email is already in use.",