feat: add weekly curriculum cover image upload and display
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
require('dotenv').config({ quiet: true });
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.hostname = '192.168.0.20';
|
||||||
|
url.port = '15437';
|
||||||
|
url.pathname = '/lms';
|
||||||
|
url.searchParams.set('connect_timeout', '5');
|
||||||
|
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const releasePath = process.env.CAMERA_RELEASE_MANIFEST || '/home/chyoo/다운로드/yahboom-lms-modules/exports/jetson-camera/release.json';
|
||||||
|
const ids = ['4eb01197-7605-4c3b-9a92-f9a3942050bd', '8e296f81-8445-455c-b1a2-47f2ce9099a4', '26d46589-31a5-4d94-b0e0-385a12c41721'];
|
||||||
|
const folderName = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws';
|
||||||
|
const image = 'future-nav2-camera-test:humble-jetson_orin';
|
||||||
|
const templateRepositoryUrl = 'https://gitea.autonomystudio.net/templates/lms-nav2-camera-test.git';
|
||||||
|
const defaults = ['video_device:=/dev/video0', 'width:=640', 'height:=480', 'fps:=30', 'publish_fps:=10', 'pixel_format:=auto'];
|
||||||
|
const snapshot = client => client.course.findMany({ where: { id: { in: ids } }, orderBy: { id: 'asc' }, include: {
|
||||||
|
weeks: { orderBy: { week: 'asc' }, include: { modules: { orderBy: { sortOrder: 'asc' } } } },
|
||||||
|
environmentProfiles: { orderBy: { id: 'asc' } },
|
||||||
|
} });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'));
|
||||||
|
assert.equal(release.runtimeImages.length, 1, 'Runtime image upload must finish first');
|
||||||
|
assert.equal(release.prebuiltArtifacts.length, 1);
|
||||||
|
assert.equal(release.runtimeImages[0].image, image);
|
||||||
|
assert.equal(release.prebuiltArtifacts[0].architecture, 'arm64');
|
||||||
|
for (const item of [...release.runtimeImages, ...release.prebuiltArtifacts]) {
|
||||||
|
assert.match(item.checksumSha256, /^[a-f0-9]{64}$/);
|
||||||
|
const check = await fetch(item.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
|
||||||
|
assert(check.ok, `Artifact unavailable: ${check.status}`);
|
||||||
|
}
|
||||||
|
const before = await snapshot(db);
|
||||||
|
assert.equal(before.length, 3, 'Expected the base curriculum and two existing courses');
|
||||||
|
assert(before.some(c => c.slug === 'futuredrive-autonomy-basic'));
|
||||||
|
for (const c of before) {
|
||||||
|
assert.notEqual(c.title, 'NAV2 알아보기');
|
||||||
|
const week = c.weeks.find(w => w.week === 3);
|
||||||
|
assert(week && week.modules.some(m => m.name === 'NAV2-MKMini'), `${c.title}: MKMini week 3 missing`);
|
||||||
|
const profiles = c.environmentProfiles.filter(p => p.week === 3);
|
||||||
|
assert.equal(profiles.filter(p => p.config.target === 'remote-jetson').length, 1);
|
||||||
|
assert(profiles.filter(p => p.config.target === 'local').every(p => p.config.moduleSet?.includeUnlisted === false),
|
||||||
|
'Local profile would implicitly include the ARM64-only camera');
|
||||||
|
}
|
||||||
|
if (apply) {
|
||||||
|
fs.mkdirSync('backups', { recursive: true });
|
||||||
|
const backup = path.resolve('backups', `week3-before-camera-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(backup, JSON.stringify(before, null, 2), { flag: 'wx', mode: 0o600 });
|
||||||
|
console.log('Backup:', backup);
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
try {
|
||||||
|
await db.$transaction(async tx => {
|
||||||
|
assert.deepEqual(await snapshot(tx), before, 'Curriculum changed during preparation');
|
||||||
|
for (const c of before) {
|
||||||
|
const week = c.weeks.find(w => w.week === 3);
|
||||||
|
const existing = week.modules.find(m => m.folderName === folderName);
|
||||||
|
const data = { name: 'NAV2-Camera', category: 'Camera', folderName, templateRepositoryUrl,
|
||||||
|
sourceType: 'GIT', dockerImage: image, defaultParameters: defaults, prebuiltArtifacts: release.prebuiltArtifacts };
|
||||||
|
const module = existing
|
||||||
|
? await tx.courseWeekModule.update({ where: { id: existing.id }, data })
|
||||||
|
: await tx.courseWeekModule.create({ data: { ...data, weekId: week.id,
|
||||||
|
sortOrder: Math.max(0, ...week.modules.map(m => m.sortOrder)) + 1 } });
|
||||||
|
const profile = c.environmentProfiles.find(p => p.week === 3 && p.config.target === 'remote-jetson');
|
||||||
|
const config = structuredClone(profile.config);
|
||||||
|
assert(Array.isArray(config.moduleSet?.modules));
|
||||||
|
config.moduleSet.modules = [...config.moduleSet.modules.filter(m => m.id !== module.id),
|
||||||
|
{ id: module.id, dockerImage: image, defaultParameters: defaults }];
|
||||||
|
config.runtimeImages = [...(config.runtimeImages || []).filter(a => a.image !== image), ...release.runtimeImages];
|
||||||
|
await tx.courseEnvironmentProfile.update({ where: { id: profile.id }, data: { config } });
|
||||||
|
summary.push({ course: c.title, week: 3, module: module.name, id: module.id, target: config.target });
|
||||||
|
}
|
||||||
|
const after = await snapshot(tx);
|
||||||
|
for (const old of before) {
|
||||||
|
const current = after.find(c => c.id === old.id);
|
||||||
|
assert.deepEqual(current.weeks.filter(w => w.week !== 3), old.weeks.filter(w => w.week !== 3));
|
||||||
|
assert.deepEqual(current.environmentProfiles.filter(p => p.week !== 3 || p.config.target !== 'remote-jetson'),
|
||||||
|
old.environmentProfiles.filter(p => p.week !== 3 || p.config.target !== 'remote-jetson'));
|
||||||
|
for (const module of old.weeks.find(w => w.week === 3).modules.filter(m => m.folderName !== folderName)) {
|
||||||
|
assert.deepEqual(current.weeks.find(w => w.week === 3).modules.find(m => m.id === module.id), module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!apply) throw new Error('DRY_RUN_ROLLBACK');
|
||||||
|
}, { isolationLevel: 'Serializable', timeout: 30000 });
|
||||||
|
} catch (e) { if (e.message !== 'DRY_RUN_ROLLBACK') throw e; }
|
||||||
|
console.log(JSON.stringify({ applied: apply, courses: summary }, null, 2));
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, 'add-week3-jetson-camera.cjs'), 'utf8')
|
||||||
|
.replace(/main\(\)\.catch[\s\S]*$/, 'module.exports = { main };');
|
||||||
|
const ids = ['4eb01197-7605-4c3b-9a92-f9a3942050bd', '8e296f81-8445-455c-b1a2-47f2ce9099a4', '26d46589-31a5-4d94-b0e0-385a12c41721'];
|
||||||
|
const image = 'future-nav2-camera-test:humble-jetson_orin';
|
||||||
|
const release = { runtimeImages: [{ image, url: 'https://example.test/image', checksumSha256: 'a'.repeat(64) }],
|
||||||
|
prebuiltArtifacts: [{ architecture: 'arm64', url: 'https://example.test/prebuilt', checksumSha256: 'b'.repeat(64) }] };
|
||||||
|
function fixture() {
|
||||||
|
return ids.map((id, i) => ({ id, title: `Course ${i}`, slug: i ? `course-${i}` : 'futuredrive-autonomy-basic',
|
||||||
|
weeks: [{ id: `${i}-w1`, week: 1, modules: [] }, { id: `${i}-w2`, week: 2, modules: [] },
|
||||||
|
{ id: `${i}-w3`, week: 3, modules: [{ id: `${i}-mk`, name: 'NAV2-MKMini', folderName: 'mkmini', sortOrder: 1 }] }],
|
||||||
|
environmentProfiles: ['local', 'remote-jetson'].map(target => ({ id: `${i}-${target}`, week: 3,
|
||||||
|
config: { target, otherSetting: 'preserve', moduleSet: { includeUnlisted: false, modules: [{ id: `${i}-mk` }] },
|
||||||
|
runtimeImages: [{ image: 'future-nav2:humble-jetson_orin', url: 'existing' }] } })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function harness(apply, initial = fixture()) {
|
||||||
|
let state = structuredClone(initial);
|
||||||
|
const db = {
|
||||||
|
course: { findMany: async () => structuredClone(state) },
|
||||||
|
courseWeekModule: {
|
||||||
|
create: async ({ data }) => {
|
||||||
|
const week = state.flatMap(c => c.weeks).find(w => w.id === data.weekId);
|
||||||
|
const m = { id: `${week.id}-camera`, ...structuredClone(data) };
|
||||||
|
week.modules.push(m); return structuredClone(m);
|
||||||
|
},
|
||||||
|
update: async ({ where, data }) => {
|
||||||
|
const m = state.flatMap(c => c.weeks.flatMap(w => w.modules)).find(m => m.id === where.id);
|
||||||
|
Object.assign(m, structuredClone(data)); return structuredClone(m);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
courseEnvironmentProfile: { update: async ({ where, data }) => {
|
||||||
|
const p = state.flatMap(c => c.environmentProfiles).find(p => p.id === where.id);
|
||||||
|
Object.assign(p, structuredClone(data)); return structuredClone(p);
|
||||||
|
} },
|
||||||
|
$transaction: async callback => {
|
||||||
|
const before = structuredClone(state);
|
||||||
|
try { return await callback(db); } catch (error) { state = before; throw error; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = { module: { exports: {} }, URL, structuredClone, console: { log() {} },
|
||||||
|
fetch: async () => ({ ok: true }), AbortSignal,
|
||||||
|
process: { argv: apply ? ['--apply'] : [], env: { DATABASE_URL: 'postgresql://user:password@localhost/lms' } },
|
||||||
|
require: name => {
|
||||||
|
if (name === 'dotenv') return { config() {} };
|
||||||
|
if (name === '@prisma/client') return { PrismaClient: function () { return db; } };
|
||||||
|
if (name === 'node:fs') return { readFileSync: () => JSON.stringify(release), mkdirSync() {}, writeFileSync() {} };
|
||||||
|
return require(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, context);
|
||||||
|
return { main: context.module.exports.main, state: () => structuredClone(state) };
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const dry = harness(false);
|
||||||
|
await dry.main();
|
||||||
|
assert.deepEqual(dry.state(), fixture());
|
||||||
|
const applied = harness(true);
|
||||||
|
await applied.main();
|
||||||
|
for (const c of applied.state()) {
|
||||||
|
assert.equal(c.weeks.find(w => w.week === 3).modules.length, 2);
|
||||||
|
assert.equal(c.weeks.find(w => w.week === 3).modules.find(m => m.name === 'NAV2-Camera').category, 'Camera');
|
||||||
|
assert.equal(c.environmentProfiles.find(p => p.config.target === 'remote-jetson').config.moduleSet.modules.length, 2);
|
||||||
|
assert.equal(c.environmentProfiles.find(p => p.config.target === 'local').config.moduleSet.modules.length, 1);
|
||||||
|
}
|
||||||
|
const once = applied.state();
|
||||||
|
await applied.main();
|
||||||
|
assert.deepEqual(applied.state(), once);
|
||||||
|
const broken = fixture();
|
||||||
|
broken[0].environmentProfiles = broken[0].environmentProfiles.filter(p => p.config.target === 'local');
|
||||||
|
const rejected = harness(true, broken);
|
||||||
|
await assert.rejects(rejected.main());
|
||||||
|
assert.deepEqual(rejected.state(), broken);
|
||||||
|
console.log('Camera registration dry-run, preservation, idempotency and profile guard tests passed.');
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Run against the LMS database. Preserve parameters and artifact settings.
|
||||||
|
UPDATE course_week_modules
|
||||||
|
SET category = 'Camera', updated_at = NOW()
|
||||||
|
WHERE folder_name = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws'
|
||||||
|
AND category = 'Perception'
|
||||||
|
RETURNING id, week_id, name, category;
|
||||||
@@ -12,7 +12,7 @@ import { readWeekCamera } from './week-camera';
|
|||||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||||
const CUSTOM_MODULE_CATEGORIES = new Set([
|
const CUSTOM_MODULE_CATEGORIES = new Set([
|
||||||
'Vehicle', 'Sensing', 'Map', 'Perception', 'Localization', 'Planning', 'Control', 'System', 'Simulator', 'Gui',
|
'Vehicle', 'Sensing', 'Camera', 'Map', 'Perception', 'Localization', 'Planning', 'Control', 'System', 'Simulator', 'Gui',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -543,6 +543,8 @@ export class CurriculumService {
|
|||||||
detail: {
|
detail: {
|
||||||
overview: override?.overview ?? week.overview,
|
overview: override?.overview ?? week.overview,
|
||||||
camera: readWeekCamera(override),
|
camera: readWeekCamera(override),
|
||||||
|
coverImage: week.references.filter((reference) => reference.kind === 'COVER_IMAGE')
|
||||||
|
.map((reference) => this.serializeWeekReference(reference, week.week))[0] ?? null,
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails,
|
topicDetails,
|
||||||
topicVideoUrls,
|
topicVideoUrls,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const WEEK_COVER_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function validateWeekCover(buffer: Buffer) {
|
||||||
|
if (!buffer.length || buffer.length > WEEK_COVER_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('대표이미지는 8MB 이하로 업로드해주세요.');
|
||||||
|
}
|
||||||
|
if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
|
||||||
|
&& buffer.toString('ascii', 12, 16) === 'IHDR') {
|
||||||
|
return { extension: '.png', contentType: 'image/png' };
|
||||||
|
}
|
||||||
|
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return { extension: '.jpg', contentType: 'image/jpeg' };
|
||||||
|
}
|
||||||
|
if (buffer.length >= 16 && buffer.toString('ascii', 0, 4) === 'RIFF'
|
||||||
|
&& buffer.toString('ascii', 8, 12) === 'WEBP') {
|
||||||
|
return { extension: '.webp', contentType: 'image/webp' };
|
||||||
|
}
|
||||||
|
throw new BadRequestException('PNG, JPEG, WebP 이미지만 업로드할 수 있습니다.');
|
||||||
|
}
|
||||||
@@ -147,6 +147,17 @@ export class InstructorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('curriculum/weeks/:week/cover-image')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 8 * 1024 * 1024 } }))
|
||||||
|
uploadCurriculumCover(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param('week', ParseIntPipe) week: number,
|
||||||
|
@UploadedFile() file: { originalname: string; buffer?: Buffer },
|
||||||
|
@Body('courseId') courseId?: string,
|
||||||
|
) {
|
||||||
|
return this.instructorService.uploadCurriculumCover(this.getBearerToken(request), { courseId, week }, file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('curriculum/weeks/:week/example-codes')
|
@Post('curriculum/weeks/:week/example-codes')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
uploadCurriculumExampleCode(
|
uploadCurriculumExampleCode(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'
|
|||||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||||
import { groupCourseScope } from './group-course-scope';
|
import { groupCourseScope } from './group-course-scope';
|
||||||
import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera';
|
import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera';
|
||||||
|
import { validateWeekCover } from '../curriculum/week-cover';
|
||||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||||
@@ -1473,6 +1474,8 @@ export class InstructorService {
|
|||||||
detail: {
|
detail: {
|
||||||
overview: override?.overview ?? week.overview,
|
overview: override?.overview ?? week.overview,
|
||||||
camera: readWeekCamera(override),
|
camera: readWeekCamera(override),
|
||||||
|
coverImage: week.references.filter((reference) => reference.kind === 'COVER_IMAGE')
|
||||||
|
.map((reference) => this.serializeWeekReference(reference, week.week))[0] ?? null,
|
||||||
learningSteps,
|
learningSteps,
|
||||||
topicDetails,
|
topicDetails,
|
||||||
topicVideoUrls,
|
topicVideoUrls,
|
||||||
@@ -1995,6 +1998,51 @@ export class InstructorService {
|
|||||||
return this.getCurriculum(accessToken, null, course.id);
|
return this.getCurriculum(accessToken, null, course.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadCurriculumCover(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
input: { courseId?: string; week: number },
|
||||||
|
file: { originalname: string; buffer?: Buffer } | undefined,
|
||||||
|
) {
|
||||||
|
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||||
|
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||||
|
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||||
|
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
|
||||||
|
if (!course) throw new NotFoundException('Course not found');
|
||||||
|
const week = course.weeks.find((item) => item.week === input.week);
|
||||||
|
if (!week) throw new NotFoundException('Course week not found');
|
||||||
|
if (!file?.buffer) throw new BadRequestException('이미지가 필요합니다.');
|
||||||
|
const format = validateWeekCover(file.buffer);
|
||||||
|
if (!this.r2PublicFileService.isConfigured()) throw new BadRequestException('이미지 저장소가 설정되지 않았습니다.');
|
||||||
|
const key = this.r2PublicFileService.createLmsCourseFileKey({
|
||||||
|
courseId: course.id, week: week.week, kind: 'COVER_IMAGE', fileId: randomUUID(), extension: format.extension,
|
||||||
|
});
|
||||||
|
await this.r2PublicFileService.uploadPublicFile({ key, body: file.buffer, contentType: format.contentType });
|
||||||
|
let previous: Array<{ storedFileName: string }> = [];
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Serialize replacements so a week cannot acquire multiple covers.
|
||||||
|
await tx.$queryRaw`SELECT id FROM course_weeks WHERE id = ${week.id}::uuid FOR UPDATE`;
|
||||||
|
previous = await tx.courseWeekReference.findMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
|
||||||
|
await tx.courseWeekReference.deleteMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
|
||||||
|
await tx.courseWeekReference.create({ data: {
|
||||||
|
weekId: week.id, kind: 'COVER_IMAGE', title: '대표이미지',
|
||||||
|
originalFileName: this.normalizeUploadedFileName(file.originalname), storedFileName: key,
|
||||||
|
contentType: format.contentType, fileSizeBytes: file.buffer!.length, sortOrder: 0,
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await this.r2PublicFileService.deletePublicFile(key).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Cloned courses may still reference the same object.
|
||||||
|
for (const old of previous) {
|
||||||
|
if (!await this.prisma.courseWeekReference.count({ where: { storedFileName: old.storedFileName } })) {
|
||||||
|
await this.r2PublicFileService.deletePublicFile(old.storedFileName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.getCurriculum(accessToken, null, course.id);
|
||||||
|
}
|
||||||
|
|
||||||
async uploadCurriculumReference(
|
async uploadCurriculumReference(
|
||||||
accessToken: string | undefined,
|
accessToken: string | undefined,
|
||||||
input: { courseId?: string | null; week: number; title?: string | null; kind?: 'REFERENCE' | 'EXAMPLE_CODE' },
|
input: { courseId?: string | null; week: number; title?: string | null; kind?: 'REFERENCE' | 'EXAMPLE_CODE' },
|
||||||
@@ -2076,6 +2124,9 @@ export class InstructorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.courseWeekReference.delete({ where: { id: reference.id } });
|
await this.prisma.courseWeekReference.delete({ where: { id: reference.id } });
|
||||||
|
const sharedCover = reference.kind === 'COVER_IMAGE'
|
||||||
|
&& await this.prisma.courseWeekReference.count({ where: { storedFileName: reference.storedFileName } }) > 0;
|
||||||
|
if (sharedCover) return this.getCurriculum(accessToken, null, reference.week.courseId);
|
||||||
if (this.r2PublicFileService.isPublicKey(reference.storedFileName)) {
|
if (this.r2PublicFileService.isPublicKey(reference.storedFileName)) {
|
||||||
await this.r2PublicFileService.deletePublicFile(reference.storedFileName).catch(() => undefined);
|
await this.r2PublicFileService.deletePublicFile(reference.storedFileName).catch(() => undefined);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { InstructorService } from './instructor.service';
|
||||||
|
import { validateWeekCover, WEEK_COVER_MAX_BYTES } from '../curriculum/week-cover';
|
||||||
|
|
||||||
|
const png = Buffer.from('89504e470d0a1a0a0000000d494844520000000100000001', 'hex');
|
||||||
|
describe('week cover images', () => {
|
||||||
|
function setup() {
|
||||||
|
const references = {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ storedFileName: 'old-cover' }]),
|
||||||
|
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'cover' }),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
const tx = { courseWeekReference: references, $queryRaw: jest.fn() };
|
||||||
|
const prisma = { courseWeekReference: references, $transaction: jest.fn(async (fn) => fn(tx)) };
|
||||||
|
const storage = {
|
||||||
|
isConfigured: () => true,
|
||||||
|
createLmsCourseFileKey: jest.fn().mockReturnValue('new-cover'),
|
||||||
|
uploadPublicFile: jest.fn().mockResolvedValue('https://files.example/new-cover'),
|
||||||
|
deletePublicFile: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new InstructorService({} as never, {} as never, prisma as never, {} as never, {} as never, storage as never);
|
||||||
|
const internals = service as any;
|
||||||
|
internals.getInstructor = jest.fn().mockResolvedValue({ authUser: { primaryAffiliation: { organizationName: 'org' } }, instructor: { id: 'teacher' } });
|
||||||
|
internals.findInstructorCourse = jest.fn().mockResolvedValue({ id: 'course', weeks: [{ id: 'week', week: 2 }] });
|
||||||
|
internals.getCurriculum = jest.fn().mockResolvedValue({ weeks: [] });
|
||||||
|
return { service, internals, prisma, references, storage, tx };
|
||||||
|
}
|
||||||
|
const input = { courseId: 'course', week: 2 };
|
||||||
|
const file = { originalname: 'photo.png', buffer: png };
|
||||||
|
|
||||||
|
it('accepts supported signatures and rejects SVG, empty and oversized uploads', () => {
|
||||||
|
expect(validateWeekCover(png).contentType).toBe('image/png');
|
||||||
|
expect(validateWeekCover(Buffer.from([255, 216, 255, 224])).extension).toBe('.jpg');
|
||||||
|
expect(validateWeekCover(Buffer.from('RIFF0000WEBPVP8 ')).extension).toBe('.webp');
|
||||||
|
for (const buffer of [Buffer.alloc(0), Buffer.from('<svg/>'), Buffer.alloc(WEEK_COVER_MAX_BYTES + 1)]) {
|
||||||
|
expect(() => validateWeekCover(buffer)).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('authorizes course ownership before uploading', async () => {
|
||||||
|
const { service, internals, storage } = setup();
|
||||||
|
internals.findInstructorCourse.mockResolvedValue(null);
|
||||||
|
await expect(service.uploadCurriculumCover('token', input, file)).rejects.toThrow('Course not found');
|
||||||
|
expect(storage.uploadPublicFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('replaces only the cover under a week lock and stores R2 metadata', async () => {
|
||||||
|
const { service, tx, references, storage } = setup();
|
||||||
|
await service.uploadCurriculumCover('token', input, file);
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalled();
|
||||||
|
expect(references.deleteMany).toHaveBeenCalledWith({ where: { weekId: 'week', kind: 'COVER_IMAGE' } });
|
||||||
|
expect(references.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: 'COVER_IMAGE', storedFileName: 'new-cover', contentType: 'image/png' }) });
|
||||||
|
expect(storage.deletePublicFile).toHaveBeenCalledWith('old-cover');
|
||||||
|
});
|
||||||
|
it('retains an old object used by a cloned course', async () => {
|
||||||
|
const { service, references, storage } = setup();
|
||||||
|
references.count.mockResolvedValue(1);
|
||||||
|
await service.uploadCurriculumCover('token', input, file);
|
||||||
|
expect(storage.deletePublicFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('cleans up the new object if database persistence fails', async () => {
|
||||||
|
const { service, prisma, storage } = setup();
|
||||||
|
prisma.$transaction.mockRejectedValue(new Error('db failed'));
|
||||||
|
await expect(service.uploadCurriculumCover('token', input, file)).rejects.toThrow('db failed');
|
||||||
|
expect(storage.deletePublicFile).toHaveBeenCalledWith('new-cover');
|
||||||
|
expect(storage.deletePublicFile).not.toHaveBeenCalledWith('old-cover');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,11 +27,12 @@ export class R2PublicFileService {
|
|||||||
createLmsCourseFileKey(input: {
|
createLmsCourseFileKey(input: {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
week: number;
|
week: number;
|
||||||
kind: 'REFERENCE' | 'EXAMPLE_CODE';
|
kind: 'REFERENCE' | 'EXAMPLE_CODE' | 'COVER_IMAGE';
|
||||||
fileId: string;
|
fileId: string;
|
||||||
extension?: string;
|
extension?: string;
|
||||||
}) {
|
}) {
|
||||||
const section = input.kind === 'EXAMPLE_CODE' ? 'example-codes' : 'references';
|
const section = input.kind === 'COVER_IMAGE' ? 'cover-images'
|
||||||
|
: input.kind === 'EXAMPLE_CODE' ? 'example-codes' : 'references';
|
||||||
return [
|
return [
|
||||||
this.publicPrefix,
|
this.publicPrefix,
|
||||||
'lms',
|
'lms',
|
||||||
|
|||||||
Reference in New Issue
Block a user