Add spotlight and uploader display name features
This commit is contained in:
parent
f8155e1127
commit
843901280e
|
|
@ -51,6 +51,7 @@ const conciergeBotRoutes = require('./routes/conciergeBot.routes');
|
||||||
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
|
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
|
||||||
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
|
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
|
||||||
const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes');
|
const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes');
|
||||||
|
const publicSpotlightRoutes = require('./routes/publicSpotlight.routes');
|
||||||
const publicEventPhotoSearchRoutes = require('./routes/publicEventPhotoSearch.routes');
|
const publicEventPhotoSearchRoutes = require('./routes/publicEventPhotoSearch.routes');
|
||||||
const publicPhotoIngestRoutes = require('./routes/publicPhotoIngest.routes');
|
const publicPhotoIngestRoutes = require('./routes/publicPhotoIngest.routes');
|
||||||
const publicSocialShareRoutes = require('./routes/publicSocialShare.routes');
|
const publicSocialShareRoutes = require('./routes/publicSocialShare.routes');
|
||||||
|
|
@ -60,6 +61,7 @@ const publicVisitorThemeRoutes = require('./routes/publicVisitorTheme.routes');
|
||||||
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
|
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
|
||||||
const eventPhotoRoutes = require('./routes/eventPhoto.routes');
|
const eventPhotoRoutes = require('./routes/eventPhoto.routes');
|
||||||
const photoWallRoutes = require('./routes/photoWall.routes');
|
const photoWallRoutes = require('./routes/photoWall.routes');
|
||||||
|
const spotlightRoutes = require('./routes/spotlight.routes');
|
||||||
const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes');
|
const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes');
|
||||||
const socialShareRoutes = require('./routes/socialShare.routes');
|
const socialShareRoutes = require('./routes/socialShare.routes');
|
||||||
const aiAvatarRoutes = require('./routes/aiAvatar.routes');
|
const aiAvatarRoutes = require('./routes/aiAvatar.routes');
|
||||||
|
|
@ -299,6 +301,7 @@ const createServer = async () => {
|
||||||
server.route(publicConciergeRoutes);
|
server.route(publicConciergeRoutes);
|
||||||
server.route(publicQuizPollRoutes);
|
server.route(publicQuizPollRoutes);
|
||||||
server.route(publicPhotoWallRoutes);
|
server.route(publicPhotoWallRoutes);
|
||||||
|
server.route(publicSpotlightRoutes);
|
||||||
server.route(publicEventPhotoSearchRoutes);
|
server.route(publicEventPhotoSearchRoutes);
|
||||||
server.route(publicPhotoIngestRoutes);
|
server.route(publicPhotoIngestRoutes);
|
||||||
server.route(publicSocialShareRoutes);
|
server.route(publicSocialShareRoutes);
|
||||||
|
|
@ -345,6 +348,7 @@ const createServer = async () => {
|
||||||
server.route(eventTicketingManualRoutes);
|
server.route(eventTicketingManualRoutes);
|
||||||
server.route(eventPhotoRoutes);
|
server.route(eventPhotoRoutes);
|
||||||
server.route(photoWallRoutes);
|
server.route(photoWallRoutes);
|
||||||
|
server.route(spotlightRoutes);
|
||||||
server.route(eventPhotoSearchRoutes);
|
server.route(eventPhotoSearchRoutes);
|
||||||
server.route(socialShareRoutes);
|
server.route(socialShareRoutes);
|
||||||
server.route(aiAvatarRoutes);
|
server.route(aiAvatarRoutes);
|
||||||
|
|
|
||||||
|
|
@ -148,13 +148,11 @@ function serializePhoto(doc) {
|
||||||
photoWall: doc.photoWall,
|
photoWall: doc.photoWall,
|
||||||
event: doc.event,
|
event: doc.event,
|
||||||
organization: doc.organization,
|
organization: doc.organization,
|
||||||
url: resolvePhotoThumbUrl(doc.s3Key || doc.url, { w: 640, h: 640 }) ||
|
url: resolvePhotoPublicUrl(doc.s3Key || doc.url) ||
|
||||||
resolvePhotoPublicUrl(doc.url) ||
|
|
||||||
doc.url,
|
doc.url,
|
||||||
s3Key: doc.s3Key,
|
s3Key: doc.s3Key,
|
||||||
framedUrl: doc.framedUrl
|
framedUrl: doc.framedUrl
|
||||||
? resolvePhotoThumbUrl(doc.framedS3Key || doc.framedUrl, { w: 640, h: 640 }) ||
|
? resolvePhotoPublicUrl(doc.framedS3Key || doc.framedUrl)
|
||||||
resolvePhotoPublicUrl(doc.framedUrl)
|
|
||||||
: null,
|
: null,
|
||||||
framedS3Key: doc.framedS3Key || null,
|
framedS3Key: doc.framedS3Key || null,
|
||||||
mimeType: doc.mimeType,
|
mimeType: doc.mimeType,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const {
|
||||||
PHOTO_KINDS,
|
PHOTO_KINDS,
|
||||||
} = require('../utils/s3EventPhotos');
|
} = require('../utils/s3EventPhotos');
|
||||||
const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap');
|
const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap');
|
||||||
|
const { normalizeUploaderDisplayName } = require('../utils/uploaderDisplayName');
|
||||||
const {
|
const {
|
||||||
EVENT_PUBLIC_SELECT,
|
EVENT_PUBLIC_SELECT,
|
||||||
buildPublicEventPayload,
|
buildPublicEventPayload,
|
||||||
|
|
@ -160,6 +161,13 @@ const uploadPhoto = async (request, h) => {
|
||||||
return h.response(error('VALIDATION_ERROR', 'Please select an avatar')).code(400);
|
return h.response(error('VALIDATION_ERROR', 'Please select an avatar')).code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = normalizeUploaderDisplayName(
|
||||||
|
await parseStringField(request.payload || {}, 'displayName'),
|
||||||
|
);
|
||||||
|
if (!displayName) {
|
||||||
|
return h.response(error('VALIDATION_ERROR', 'Your name is required and must be 80 characters or fewer')).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
const template = await AiAvatarTemplate.findOne({
|
const template = await AiAvatarTemplate.findOne({
|
||||||
_id: avatarId,
|
_id: avatarId,
|
||||||
aiAvatar: config._id,
|
aiAvatar: config._id,
|
||||||
|
|
@ -217,6 +225,7 @@ const uploadPhoto = async (request, h) => {
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
size: file.buffer.length,
|
size: file.buffer.length,
|
||||||
originalFileName: file.originalName,
|
originalFileName: file.originalName,
|
||||||
|
displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
jest.mock('../models/AiAvatar', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/AiAvatarTemplate', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/AiAvatarOutput', () => ({ create: jest.fn() }));
|
||||||
|
jest.mock('../services/waveSpeedHeadSwap', () => ({ runImageHeadSwap: jest.fn() }));
|
||||||
|
jest.mock('../utils/s3EventPhotos', () => ({
|
||||||
|
uploadEventPhotoToS3: jest.fn(),
|
||||||
|
getEventPhotoBufferFromS3: jest.fn(),
|
||||||
|
resolvePhotoPublicUrl: jest.fn((url) => url),
|
||||||
|
resolvePhotoThumbUrl: jest.fn(() => 'preview-url'),
|
||||||
|
PHOTO_ACTIVITIES: { aiAvatar: 'ai-avatar' },
|
||||||
|
PHOTO_KINDS: { uploads: 'uploads', results: 'results' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AiAvatar = require('../models/AiAvatar');
|
||||||
|
const AiAvatarTemplate = require('../models/AiAvatarTemplate');
|
||||||
|
const AiAvatarOutput = require('../models/AiAvatarOutput');
|
||||||
|
const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap');
|
||||||
|
const { uploadEventPhotoToS3 } = require('../utils/s3EventPhotos');
|
||||||
|
const { uploadPhoto } = require('./publicAiAvatar.controller');
|
||||||
|
|
||||||
|
const uploadToken = 'ai-avatar-token';
|
||||||
|
const avatarId = '507f1f77bcf86cd799439021';
|
||||||
|
const config = {
|
||||||
|
_id: '507f1f77bcf86cd799439022',
|
||||||
|
event: '507f1f77bcf86cd799439023',
|
||||||
|
organization: '507f1f77bcf86cd799439024',
|
||||||
|
enabled: true,
|
||||||
|
accessMode: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeToolkit() {
|
||||||
|
return {
|
||||||
|
response(payload) {
|
||||||
|
return { payload, code: (statusCode) => ({ payload, statusCode }) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(displayName = ' Ada Lovelace ') {
|
||||||
|
return {
|
||||||
|
params: { uploadToken },
|
||||||
|
auth: { credentials: {} },
|
||||||
|
payload: {
|
||||||
|
avatarId,
|
||||||
|
displayName,
|
||||||
|
file: {
|
||||||
|
_data: Buffer.from('image'),
|
||||||
|
hapi: { filename: 'photo.jpg', headers: { 'content-type': 'image/jpeg' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('public AI Avatar uploader name', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
AiAvatar.findOne.mockResolvedValue(config);
|
||||||
|
AiAvatarTemplate.findOne.mockReturnValue({
|
||||||
|
lean: jest.fn().mockResolvedValue({ _id: avatarId, imageUrl: 'avatar.png' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a missing name before uploading the photo', async () => {
|
||||||
|
const response = await uploadPhoto(makeRequest(' '), makeToolkit());
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(uploadEventPhotoToS3).not.toHaveBeenCalled();
|
||||||
|
expect(AiAvatarOutput.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the trimmed name on the output record', async () => {
|
||||||
|
const output = {
|
||||||
|
_id: { toString: () => '507f1f77bcf86cd799439025' },
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
AiAvatarOutput.create.mockResolvedValue(output);
|
||||||
|
uploadEventPhotoToS3
|
||||||
|
.mockResolvedValueOnce({ url: 'source-url', s3Key: 'source-key' })
|
||||||
|
.mockResolvedValueOnce({ url: 'result-url', s3Key: 'result-key' });
|
||||||
|
runImageHeadSwap.mockResolvedValue({ buffer: Buffer.from('result') });
|
||||||
|
|
||||||
|
const response = await uploadPhoto(makeRequest(), makeToolkit());
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
expect(AiAvatarOutput.create).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ada Lovelace' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -559,7 +559,6 @@ const getPresentWall = async (request, h) => {
|
||||||
return {
|
return {
|
||||||
_id: p._id,
|
_id: p._id,
|
||||||
url:
|
url:
|
||||||
resolvePhotoThumbUrl(rawKey, { w: 640, h: 640 }) ||
|
|
||||||
resolvePhotoPublicUrl(rawKey) ||
|
resolvePhotoPublicUrl(rawKey) ||
|
||||||
rawKey,
|
rawKey,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const {
|
||||||
PHOTO_KINDS,
|
PHOTO_KINDS,
|
||||||
} = require('../utils/s3EventPhotos');
|
} = require('../utils/s3EventPhotos');
|
||||||
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
|
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
|
||||||
|
const { normalizeUploaderDisplayName } = require('../utils/uploaderDisplayName');
|
||||||
const {
|
const {
|
||||||
EVENT_PUBLIC_SELECT,
|
EVENT_PUBLIC_SELECT,
|
||||||
buildPublicEventPayload,
|
buildPublicEventPayload,
|
||||||
|
|
@ -186,6 +187,13 @@ const uploadPhoto = async (request, h) => {
|
||||||
return h.response(error('VALIDATION_ERROR', 'Please select a frame')).code(400);
|
return h.response(error('VALIDATION_ERROR', 'Please select a frame')).code(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = normalizeUploaderDisplayName(
|
||||||
|
await parseStringField(request.payload || {}, 'displayName'),
|
||||||
|
);
|
||||||
|
if (!displayName) {
|
||||||
|
return h.response(error('VALIDATION_ERROR', 'Your name is required and must be 80 characters or fewer')).code(400);
|
||||||
|
}
|
||||||
|
|
||||||
const frame = await SocialShareFrame.findOne({
|
const frame = await SocialShareFrame.findOne({
|
||||||
_id: frameId,
|
_id: frameId,
|
||||||
socialShare: config._id,
|
socialShare: config._id,
|
||||||
|
|
@ -239,6 +247,7 @@ const uploadPhoto = async (request, h) => {
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
size: file.buffer.length,
|
size: file.buffer.length,
|
||||||
originalFileName: file.originalName,
|
originalFileName: file.originalName,
|
||||||
|
displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
jest.mock('../models/SocialShare', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/SocialShareFrame', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/SocialShareOutput', () => ({ create: jest.fn() }));
|
||||||
|
jest.mock('../utils/s3EventPhotos', () => ({
|
||||||
|
uploadEventPhotoToS3: jest.fn(),
|
||||||
|
getEventPhotoBufferFromS3: jest.fn(),
|
||||||
|
resolvePhotoPublicUrl: jest.fn((url) => url),
|
||||||
|
resolvePhotoThumbUrl: jest.fn(() => 'preview-url'),
|
||||||
|
PHOTO_ACTIVITIES: { socialShare: 'social-share' },
|
||||||
|
PHOTO_KINDS: { uploads: 'uploads', framed: 'framed' },
|
||||||
|
}));
|
||||||
|
jest.mock('../utils/photoWallFrameRender', () => ({ renderAndUploadFramedPhoto: jest.fn() }));
|
||||||
|
|
||||||
|
const SocialShare = require('../models/SocialShare');
|
||||||
|
const SocialShareFrame = require('../models/SocialShareFrame');
|
||||||
|
const SocialShareOutput = require('../models/SocialShareOutput');
|
||||||
|
const { uploadEventPhotoToS3 } = require('../utils/s3EventPhotos');
|
||||||
|
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
|
||||||
|
const { uploadPhoto } = require('./publicSocialShare.controller');
|
||||||
|
|
||||||
|
const uploadToken = 'social-share-token';
|
||||||
|
const frameId = '507f1f77bcf86cd799439011';
|
||||||
|
const config = {
|
||||||
|
_id: '507f1f77bcf86cd799439012',
|
||||||
|
event: '507f1f77bcf86cd799439013',
|
||||||
|
organization: '507f1f77bcf86cd799439014',
|
||||||
|
enabled: true,
|
||||||
|
accessMode: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeToolkit() {
|
||||||
|
return {
|
||||||
|
response(payload) {
|
||||||
|
return { payload, code: (statusCode) => ({ payload, statusCode }) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(displayName = ' Ada Lovelace ') {
|
||||||
|
return {
|
||||||
|
params: { uploadToken },
|
||||||
|
auth: { credentials: {} },
|
||||||
|
payload: {
|
||||||
|
frameId,
|
||||||
|
displayName,
|
||||||
|
file: {
|
||||||
|
_data: Buffer.from('image'),
|
||||||
|
hapi: { filename: 'photo.jpg', headers: { 'content-type': 'image/jpeg' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('public Social Share uploader name', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
SocialShare.findOne.mockResolvedValue(config);
|
||||||
|
SocialShareFrame.findOne.mockReturnValue({
|
||||||
|
lean: jest.fn().mockResolvedValue({ _id: frameId, frameImageUrl: 'frame.png' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a missing name before uploading the photo', async () => {
|
||||||
|
const response = await uploadPhoto(makeRequest(' '), makeToolkit());
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(uploadEventPhotoToS3).not.toHaveBeenCalled();
|
||||||
|
expect(SocialShareOutput.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the trimmed name on the output record', async () => {
|
||||||
|
const output = {
|
||||||
|
_id: { toString: () => '507f1f77bcf86cd799439015' },
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
SocialShareOutput.create.mockResolvedValue(output);
|
||||||
|
uploadEventPhotoToS3.mockResolvedValue({ url: 'source-url', s3Key: 'source-key' });
|
||||||
|
renderAndUploadFramedPhoto.mockResolvedValue({ framedUrl: 'framed-url', framedS3Key: 'framed-key' });
|
||||||
|
|
||||||
|
const response = await uploadPhoto(makeRequest(), makeToolkit());
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
expect(SocialShareOutput.create).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ada Lovelace' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const Spotlight = require('../models/Spotlight');
|
||||||
|
const Event = require('../models/Event');
|
||||||
|
const EventMicrosite = require('../models/EventMicrosite');
|
||||||
|
const PhotoWall = require('../models/PhotoWall');
|
||||||
|
const PhotoWallPhoto = require('../models/PhotoWallPhoto');
|
||||||
|
const SocialShare = require('../models/SocialShare');
|
||||||
|
const SocialShareOutput = require('../models/SocialShareOutput');
|
||||||
|
const AiAvatar = require('../models/AiAvatar');
|
||||||
|
const AiAvatarOutput = require('../models/AiAvatarOutput');
|
||||||
|
const QuizPoll = require('../models/QuizPoll');
|
||||||
|
const { success, error } = require('../utils/response');
|
||||||
|
const {
|
||||||
|
buildPublicEventPayload,
|
||||||
|
EVENT_PUBLIC_SELECT,
|
||||||
|
normalizePresenterEventHeaderDisplay,
|
||||||
|
} = require('../utils/formPublicHelpers');
|
||||||
|
const { resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos');
|
||||||
|
|
||||||
|
const SOURCE_KEYS = ['socialShare', 'aiAvatar', 'quiz', 'photoWall'];
|
||||||
|
|
||||||
|
function serialize(config, photoCount = 0) {
|
||||||
|
const value = config.toObject ? config.toObject() : config;
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
_id: String(value._id),
|
||||||
|
event: String(value.event),
|
||||||
|
organization: String(value.organization),
|
||||||
|
enabled: Boolean(value.enabled),
|
||||||
|
sources: Object.fromEntries(SOURCE_KEYS.map((key) => [key, value.sources?.[key] !== false])),
|
||||||
|
presentToken: value.presentToken || null,
|
||||||
|
photoCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreate(eventId) {
|
||||||
|
const event = await Event.findById(eventId).select('organization').lean();
|
||||||
|
if (!event) return null;
|
||||||
|
let config = await Spotlight.findOne({ event: eventId });
|
||||||
|
if (!config) {
|
||||||
|
config = await Spotlight.create({
|
||||||
|
event: eventId,
|
||||||
|
organization: event.organization,
|
||||||
|
presentToken: Spotlight.generateToken(),
|
||||||
|
});
|
||||||
|
} else if (!config.presentToken) {
|
||||||
|
config.presentToken = Spotlight.generateToken();
|
||||||
|
await config.save();
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAccess(eventId, user) {
|
||||||
|
if (user.role === 'super_admin') return true;
|
||||||
|
const orgId = user.organization?._id || user.organization;
|
||||||
|
const event = await Event.findById(eventId).select('organization').lean();
|
||||||
|
return Boolean(event && event.organization?.toString() === orgId?.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageRow(id, source, url, createdAt, displayName = null) {
|
||||||
|
const publicUrl = resolvePhotoPublicUrl(url) || url;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: `${source}:${id}`,
|
||||||
|
source,
|
||||||
|
url: publicUrl,
|
||||||
|
createdAt,
|
||||||
|
displayName: displayName || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos(eventId, enabledSources, requestedSources = SOURCE_KEYS) {
|
||||||
|
const active = SOURCE_KEYS.filter(
|
||||||
|
(key) => enabledSources[key] && requestedSources.includes(key)
|
||||||
|
);
|
||||||
|
const tasks = [];
|
||||||
|
if (active.includes('photoWall')) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const wall = await PhotoWall.findOne({ event: eventId, enabled: true }).select('_id').lean();
|
||||||
|
if (!wall) return [];
|
||||||
|
const rows = await PhotoWallPhoto.find({ photoWall: wall._id, status: 'approved' })
|
||||||
|
.sort({ createdAt: -1 }).limit(500).select('_id url framedUrl displayName createdAt').lean();
|
||||||
|
return rows.map((row) => imageRow(row._id, 'photoWall', row.framedUrl || row.url, row.createdAt, row.displayName));
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
if (active.includes('socialShare')) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const config = await SocialShare.findOne({ event: eventId, enabled: true }).select('_id').lean();
|
||||||
|
if (!config) return [];
|
||||||
|
const rows = await SocialShareOutput.find({ socialShare: config._id })
|
||||||
|
.sort({ createdAt: -1 }).limit(500).select('_id url framedUrl displayName createdAt').lean();
|
||||||
|
return rows.map((row) => imageRow(row._id, 'socialShare', row.framedUrl || row.url, row.createdAt, row.displayName));
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
if (active.includes('aiAvatar')) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const config = await AiAvatar.findOne({ event: eventId, enabled: true }).select('_id').lean();
|
||||||
|
if (!config) return [];
|
||||||
|
const rows = await AiAvatarOutput.find({ aiAvatar: config._id, resultUrl: { $nin: [null, ''] } })
|
||||||
|
.sort({ createdAt: -1 }).limit(500).select('_id resultUrl displayName createdAt').lean();
|
||||||
|
return rows.map((row) => imageRow(row._id, 'aiAvatar', row.resultUrl, row.createdAt, row.displayName));
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
if (active.includes('quiz')) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const quizzes = await QuizPoll.find({ event: eventId, deletedAt: null, 'settings.status': 'active' })
|
||||||
|
.select('_id questions createdAt').lean();
|
||||||
|
const rows = [];
|
||||||
|
for (const quiz of quizzes) {
|
||||||
|
for (const question of quiz.questions || []) {
|
||||||
|
if (question.imageUrl) rows.push(imageRow(`${quiz._id}:${question._id}:question`, 'quiz', question.imageUrl, quiz.createdAt));
|
||||||
|
for (const option of question.options || []) {
|
||||||
|
if (option.imageUrl) rows.push(imageRow(`${quiz._id}:${question._id}:${option._id}`, 'quiz', option.imageUrl, quiz.createdAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
return (await Promise.all(tasks)).flat().sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)).slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSpotlight = async (request, h) => {
|
||||||
|
const user = request.auth.credentials?.user;
|
||||||
|
const { eventId } = request.params;
|
||||||
|
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(eventId)) return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
|
||||||
|
if (!(await assertAccess(eventId, user))) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
||||||
|
const config = await getOrCreate(eventId);
|
||||||
|
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
|
||||||
|
const sources = serialize(config).sources;
|
||||||
|
const photos = await loadPhotos(eventId, sources);
|
||||||
|
return h.response(success({ ...serialize(config, photos.length), photos })).code(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSpotlight = async (request, h) => {
|
||||||
|
const user = request.auth.credentials?.user;
|
||||||
|
const { eventId } = request.params;
|
||||||
|
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(eventId)) return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
|
||||||
|
if (!(await assertAccess(eventId, user))) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
||||||
|
const config = await getOrCreate(eventId);
|
||||||
|
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
|
||||||
|
const p = request.payload || {};
|
||||||
|
if (typeof p.enabled === 'boolean') config.enabled = p.enabled;
|
||||||
|
if (p.sources && typeof p.sources === 'object') {
|
||||||
|
for (const key of SOURCE_KEYS) if (typeof p.sources[key] === 'boolean') config.sources[key] = p.sources[key];
|
||||||
|
}
|
||||||
|
for (const key of ['photosPerRow', 'totalRows', 'maxPhotoWidth', 'maxPhotoHeight', 'gapPx', 'presentMarginTopPx', 'presentMarginLeftPx', 'presentMarginRightPx', 'frameCanvasWidth', 'frameCanvasHeight']) {
|
||||||
|
if (p[key] != null) config[key] = Number(p[key]);
|
||||||
|
}
|
||||||
|
if (['left', 'right', 'alternate'].includes(p.marqueeDirection)) config.marqueeDirection = p.marqueeDirection;
|
||||||
|
if (['rect', 'rounded', 'circle'].includes(p.presentPhotoShape)) config.presentPhotoShape = p.presentPhotoShape;
|
||||||
|
if (typeof p.showUploaderNameOnWall === 'boolean') config.showUploaderNameOnWall = p.showUploaderNameOnWall;
|
||||||
|
if (typeof p.frameEnabled === 'boolean') config.frameEnabled = p.frameEnabled;
|
||||||
|
if (p.frameImageUrl !== undefined) config.frameImageUrl = p.frameImageUrl ? String(p.frameImageUrl).trim() : null;
|
||||||
|
if (p.frameImageLayout && typeof p.frameImageLayout === 'object') config.frameImageLayout = p.frameImageLayout;
|
||||||
|
if (Array.isArray(p.frameLayout)) config.frameLayout = p.frameLayout;
|
||||||
|
if (p.presenterEventHeader && typeof p.presenterEventHeader === 'object') config.presenterEventHeader = p.presenterEventHeader;
|
||||||
|
if (p.regenerateTokens === true) config.presentToken = Spotlight.generateToken();
|
||||||
|
await config.save();
|
||||||
|
const sources = serialize(config).sources;
|
||||||
|
const photos = await loadPhotos(eventId, sources);
|
||||||
|
return h.response(success({ ...serialize(config, photos.length), photos }, 'Spotlight updated')).code(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPresent = async (request, h) => {
|
||||||
|
const config = await Spotlight.findOne({ presentToken: String(request.params.presentToken || '').trim(), enabled: true }).lean();
|
||||||
|
if (!config) return h.response(error('NOT_FOUND', 'Spotlight not available')).code(404);
|
||||||
|
const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean();
|
||||||
|
const microsite = event ? await EventMicrosite.findOne({ event: config.event }).select('logo').lean() : null;
|
||||||
|
const eventPublic = event ? buildPublicEventPayload(event, microsite?.logo) : null;
|
||||||
|
const sources = Object.fromEntries(SOURCE_KEYS.map((key) => [key, config.sources?.[key] !== false]));
|
||||||
|
const hasSourceFilter = Object.prototype.hasOwnProperty.call(request.query || {}, 'sources');
|
||||||
|
const requested = hasSourceFilter
|
||||||
|
? String(request.query.sources || '').split(',').filter((key) => SOURCE_KEYS.includes(key))
|
||||||
|
: SOURCE_KEYS;
|
||||||
|
const photos = await loadPhotos(config.event, sources, requested);
|
||||||
|
return h.response(success({
|
||||||
|
eventId: String(config.event),
|
||||||
|
eventName: eventPublic?.name || event?.name || 'Event',
|
||||||
|
event: eventPublic,
|
||||||
|
eventHeader: normalizePresenterEventHeaderDisplay(config.presenterEventHeader),
|
||||||
|
sources,
|
||||||
|
photosPerRow: config.photosPerRow ?? 4,
|
||||||
|
totalRows: config.totalRows ?? 3,
|
||||||
|
marqueeDirection: config.marqueeDirection || 'alternate',
|
||||||
|
maxPhotoWidth: config.maxPhotoWidth ?? 320,
|
||||||
|
maxPhotoHeight: config.maxPhotoHeight ?? 320,
|
||||||
|
gapPx: config.gapPx ?? 8,
|
||||||
|
presentMarginTopPx: config.presentMarginTopPx ?? 0,
|
||||||
|
presentMarginLeftPx: config.presentMarginLeftPx ?? 0,
|
||||||
|
presentMarginRightPx: config.presentMarginRightPx ?? 0,
|
||||||
|
presentPhotoShape: config.presentPhotoShape || 'rounded',
|
||||||
|
showUploaderNameOnWall: Boolean(config.showUploaderNameOnWall),
|
||||||
|
frameEnabled: Boolean(config.frameEnabled),
|
||||||
|
frameImageUrl: config.frameImageUrl || null,
|
||||||
|
frameCanvasWidth: config.frameCanvasWidth ?? 1080,
|
||||||
|
frameCanvasHeight: config.frameCanvasHeight ?? 1080,
|
||||||
|
frameImageLayout: config.frameImageLayout,
|
||||||
|
frameLayout: config.frameLayout || [],
|
||||||
|
photos,
|
||||||
|
})).code(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getSpotlight, updateSpotlight, getPresent };
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
jest.mock('../models/Spotlight', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/Event', () => ({ findById: jest.fn() }));
|
||||||
|
jest.mock('../models/EventMicrosite', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/SocialShare', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/SocialShareOutput', () => ({ find: jest.fn() }));
|
||||||
|
jest.mock('../models/AiAvatar', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/AiAvatarOutput', () => ({ find: jest.fn() }));
|
||||||
|
jest.mock('../models/PhotoWall', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../models/PhotoWallPhoto', () => ({ find: jest.fn() }));
|
||||||
|
jest.mock('../models/QuizPoll', () => ({ find: jest.fn() }));
|
||||||
|
jest.mock('../utils/formPublicHelpers', () => ({
|
||||||
|
EVENT_PUBLIC_SELECT: 'publicFields',
|
||||||
|
buildPublicEventPayload: jest.fn(() => ({ name: 'Event' })),
|
||||||
|
normalizePresenterEventHeaderDisplay: jest.fn((value) => value),
|
||||||
|
}));
|
||||||
|
jest.mock('../utils/s3EventPhotos', () => ({
|
||||||
|
resolvePhotoPublicUrl: jest.fn((url) => url),
|
||||||
|
resolvePhotoThumbUrl: jest.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Spotlight = require('../models/Spotlight');
|
||||||
|
const Event = require('../models/Event');
|
||||||
|
const EventMicrosite = require('../models/EventMicrosite');
|
||||||
|
const SocialShare = require('../models/SocialShare');
|
||||||
|
const SocialShareOutput = require('../models/SocialShareOutput');
|
||||||
|
const { getPresent } = require('./spotlight.controller');
|
||||||
|
|
||||||
|
function findQuery(result) {
|
||||||
|
return {
|
||||||
|
sort: () => ({
|
||||||
|
limit: () => ({
|
||||||
|
select: () => ({ lean: async () => result }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Spotlight uploader names', () => {
|
||||||
|
test('returns the stored Social Share name with the public photo row', async () => {
|
||||||
|
const eventId = '507f1f77bcf86cd799439031';
|
||||||
|
const socialShareId = '507f1f77bcf86cd799439032';
|
||||||
|
Spotlight.findOne.mockReturnValue({
|
||||||
|
lean: async () => ({
|
||||||
|
event: eventId,
|
||||||
|
enabled: true,
|
||||||
|
sources: { socialShare: true, aiAvatar: false, quiz: false, photoWall: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Event.findById.mockReturnValue({ select: () => ({ lean: async () => ({ name: 'Event' }) }) });
|
||||||
|
EventMicrosite.findOne.mockReturnValue({ select: () => ({ lean: async () => null }) });
|
||||||
|
SocialShare.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ _id: socialShareId }) }) });
|
||||||
|
SocialShareOutput.find.mockReturnValue(findQuery([{
|
||||||
|
_id: '507f1f77bcf86cd799439033',
|
||||||
|
url: 'original.jpg',
|
||||||
|
framedUrl: 'framed.jpg',
|
||||||
|
displayName: 'Ada Lovelace',
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
}]));
|
||||||
|
|
||||||
|
const h = {
|
||||||
|
response: (payload) => ({ payload, code: (statusCode) => ({ payload, statusCode }) }),
|
||||||
|
};
|
||||||
|
const response = await getPresent({
|
||||||
|
params: { presentToken: 'spotlight-token' },
|
||||||
|
query: {},
|
||||||
|
}, h);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.payload.data.photos[0]).toEqual(expect.objectContaining({
|
||||||
|
source: 'socialShare',
|
||||||
|
displayName: 'Ada Lovelace',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,6 +32,7 @@ const aiAvatarOutputSchema = new mongoose.Schema(
|
||||||
mimeType: { type: String },
|
mimeType: { type: String },
|
||||||
size: { type: Number },
|
size: { type: Number },
|
||||||
originalFileName: { type: String },
|
originalFileName: { type: String },
|
||||||
|
displayName: { type: String, trim: true, maxlength: 80, default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: true },
|
{ timestamps: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const socialShareOutputSchema = new mongoose.Schema(
|
||||||
mimeType: { type: String },
|
mimeType: { type: String },
|
||||||
size: { type: Number },
|
size: { type: Number },
|
||||||
originalFileName: { type: String },
|
originalFileName: { type: String },
|
||||||
|
displayName: { type: String, trim: true, maxlength: 80, default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: true },
|
{ timestamps: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const spotlightSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
|
||||||
|
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true },
|
||||||
|
enabled: { type: Boolean, default: false },
|
||||||
|
sources: {
|
||||||
|
socialShare: { type: Boolean, default: true },
|
||||||
|
aiAvatar: { type: Boolean, default: true },
|
||||||
|
quiz: { type: Boolean, default: true },
|
||||||
|
photoWall: { type: Boolean, default: true },
|
||||||
|
},
|
||||||
|
photosPerRow: { type: Number, default: 4, min: 1, max: 12 },
|
||||||
|
totalRows: { type: Number, default: 3, min: 1, max: 8 },
|
||||||
|
marqueeDirection: { type: String, enum: ['left', 'right', 'alternate'], default: 'alternate' },
|
||||||
|
maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 },
|
||||||
|
maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 },
|
||||||
|
gapPx: { type: Number, default: 8, min: 0, max: 64 },
|
||||||
|
presentMarginTopPx: { type: Number, default: 0, min: 0, max: 2000 },
|
||||||
|
presentMarginLeftPx: { type: Number, default: 0, min: 0, max: 2000 },
|
||||||
|
presentMarginRightPx: { type: Number, default: 0, min: 0, max: 2000 },
|
||||||
|
presentPhotoShape: { type: String, enum: ['rect', 'rounded', 'circle'], default: 'rounded' },
|
||||||
|
showUploaderNameOnWall: { type: Boolean, default: false },
|
||||||
|
frameEnabled: { type: Boolean, default: false },
|
||||||
|
frameImageUrl: { type: String, trim: true, default: null },
|
||||||
|
frameImageLayout: { type: mongoose.Schema.Types.Mixed, default: () => ({ x: 0, y: 0, w: 100, h: 100 }) },
|
||||||
|
frameCanvasWidth: { type: Number, default: 1080, min: 200, max: 4096 },
|
||||||
|
frameCanvasHeight: { type: Number, default: 1080, min: 200, max: 4096 },
|
||||||
|
frameLayout: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
||||||
|
presenterEventHeader: {
|
||||||
|
showLogo: { type: Boolean, default: false },
|
||||||
|
showBanner: { type: Boolean, default: false },
|
||||||
|
showTitle: { type: Boolean, default: true },
|
||||||
|
showDateRange: { type: Boolean, default: false },
|
||||||
|
showEventType: { type: Boolean, default: false },
|
||||||
|
showLocationType: { type: Boolean, default: false },
|
||||||
|
showVenue: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
presentToken: { type: String, trim: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
spotlightSchema.index({ event: 1 }, { unique: true });
|
||||||
|
spotlightSchema.index({ presentToken: 1 }, { unique: true, sparse: true });
|
||||||
|
spotlightSchema.statics.generateToken = function generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Spotlight', spotlightSchema);
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
const controller = require('../controllers/spotlight.controller');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/public/spotlight/present/{presentToken}',
|
||||||
|
options: { auth: false, handler: controller.getPresent },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
const Joi = require('joi');
|
||||||
|
const controller = require('../controllers/spotlight.controller');
|
||||||
|
|
||||||
|
const sources = Joi.object({
|
||||||
|
socialShare: Joi.boolean(),
|
||||||
|
aiAvatar: Joi.boolean(),
|
||||||
|
quiz: Joi.boolean(),
|
||||||
|
photoWall: Joi.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePayload = Joi.object({
|
||||||
|
enabled: Joi.boolean(),
|
||||||
|
sources,
|
||||||
|
photosPerRow: Joi.number().integer().min(1).max(12),
|
||||||
|
totalRows: Joi.number().integer().min(1).max(8),
|
||||||
|
marqueeDirection: Joi.string().valid('left', 'right', 'alternate'),
|
||||||
|
maxPhotoWidth: Joi.number().integer().min(80).max(1200),
|
||||||
|
maxPhotoHeight: Joi.number().integer().min(80).max(1200),
|
||||||
|
gapPx: Joi.number().integer().min(0).max(64),
|
||||||
|
presentMarginTopPx: Joi.number().integer().min(0).max(2000),
|
||||||
|
presentMarginLeftPx: Joi.number().integer().min(0).max(2000),
|
||||||
|
presentMarginRightPx: Joi.number().integer().min(0).max(2000),
|
||||||
|
presentPhotoShape: Joi.string().valid('rect', 'rounded', 'circle'),
|
||||||
|
showUploaderNameOnWall: Joi.boolean(),
|
||||||
|
frameEnabled: Joi.boolean(),
|
||||||
|
frameImageUrl: Joi.alternatives().try(Joi.string().allow('').max(2000), Joi.valid(null)),
|
||||||
|
frameImageLayout: Joi.object().unknown(true),
|
||||||
|
frameCanvasWidth: Joi.number().integer().min(200).max(4096),
|
||||||
|
frameCanvasHeight: Joi.number().integer().min(200).max(4096),
|
||||||
|
frameLayout: Joi.array().items(Joi.object().unknown(true)).max(40),
|
||||||
|
presenterEventHeader: Joi.object({
|
||||||
|
showLogo: Joi.boolean(), showBanner: Joi.boolean(), showTitle: Joi.boolean(),
|
||||||
|
showDateRange: Joi.boolean(), showEventType: Joi.boolean(), showLocationType: Joi.boolean(), showVenue: Joi.boolean(),
|
||||||
|
}),
|
||||||
|
regenerateTokens: Joi.boolean(),
|
||||||
|
}).min(1);
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{ method: 'GET', path: '/api/events/{eventId}/spotlight', options: { auth: 'jwt', handler: controller.getSpotlight } },
|
||||||
|
{ method: 'PATCH', path: '/api/events/{eventId}/spotlight', options: { auth: 'jwt', validate: { payload: updatePayload }, handler: controller.updateSpotlight } },
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
const MAX_UPLOADER_DISPLAY_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
function normalizeUploaderDisplayName(value) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const displayName = value.trim();
|
||||||
|
if (!displayName || displayName.length > MAX_UPLOADER_DISPLAY_NAME_LENGTH) return null;
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MAX_UPLOADER_DISPLAY_NAME_LENGTH, normalizeUploaderDisplayName };
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
const {
|
||||||
|
MAX_UPLOADER_DISPLAY_NAME_LENGTH,
|
||||||
|
normalizeUploaderDisplayName,
|
||||||
|
} = require('./uploaderDisplayName');
|
||||||
|
|
||||||
|
describe('normalizeUploaderDisplayName', () => {
|
||||||
|
test('trims and accepts a non-empty name', () => {
|
||||||
|
expect(normalizeUploaderDisplayName(' Ada Lovelace ')).toBe('Ada Lovelace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing and whitespace-only values', () => {
|
||||||
|
expect(normalizeUploaderDisplayName(undefined)).toBeNull();
|
||||||
|
expect(normalizeUploaderDisplayName(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts the maximum length and rejects longer values', () => {
|
||||||
|
expect(normalizeUploaderDisplayName('a'.repeat(MAX_UPLOADER_DISPLAY_NAME_LENGTH))).toHaveLength(80);
|
||||||
|
expect(normalizeUploaderDisplayName('a'.repeat(MAX_UPLOADER_DISPLAY_NAME_LENGTH + 1))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue