Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ mounted outside production.
configured separately for archives.
- Upload defaults are 90 MB for base/demo videos and 80 MB for contributor
recordings. File contents are verified by signature, not browser MIME type.
- Reference and demo-video uploads accept browser-reported `width` and `height`
in pixels, each from 0 to 16,384. Zero represents unknown dimensions; these
values are metadata for responsive playback, not server-side media probes.
- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair.
Atomic claiming prevents simultaneous duplicate submissions; failed processing
releases the claim.
Expand All @@ -121,7 +124,7 @@ npm test
The command runs Node's built-in test runner serially across every
`tests/*.test.js` file. The API suites use MongoDB Memory Server unless
`MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests,
so use only a disposable database. Provider delivery is stubbed. The 56 cases and
so use only a disposable database. Provider delivery is stubbed. The 57 cases and
their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md).

## Current limitation
Expand Down
8 changes: 8 additions & 0 deletions backend/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ The video and demo-video upload routes use `multipart/form-data`:
| `categoryId` | Optional category ID | Not accepted |
| `status` | Optional video status | Not accepted |
| `durationSec` | Optional duration in seconds | Optional duration in seconds |
| `width` | Optional displayed width in pixels | Optional displayed width in pixels |
| `height` | Optional displayed height in pixels | Optional displayed height in pixels |

`width` and `height` are integers from 0 through 16,384 and default to `0`
when omitted or unknown. They are reported by the admin's browser during video
preparation (including rotation metadata); the API stores the values but does
not probe media files to verify them. The frontend uses the stored dimensions
to size responsive video frames before playback metadata loads.

Submission creation uses `multipart/form-data` with a required `recording` file
and `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration` fields. Only
Expand Down
115 changes: 59 additions & 56 deletions backend/docs/TEST-CASES.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.1",
"version": "1.0.2",
"description": "SignPakCommons API: Express, Node.js and MongoDB",
"main": "server.js",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions backend/src/docs/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export const openapi = {
categoryId: { type: 'string', nullable: true },
order: { type: 'integer' },
durationSec: { type: 'number' },
width: { type: 'integer', description: 'Displayed width in pixels; 0 when unknown.' },
height: { type: 'integer', description: 'Displayed height in pixels; 0 when unknown.' },
videoUrl: { type: 'string' },
poster: { type: 'string', nullable: true },
size: { type: 'integer' },
Expand Down Expand Up @@ -213,6 +215,8 @@ export const openapi = {
categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' },
status: { type: 'string', enum: ['draft', 'published'], default: 'published' },
durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 },
width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 },
height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 },
},
},
DemoVideo: {
Expand All @@ -222,6 +226,8 @@ export const openapi = {
id: { type: 'string', enum: ['current'] },
title: { type: 'string', maxLength: 120 },
durationSec: { type: 'number' },
width: { type: 'integer' },
height: { type: 'integer' },
videoUrl: { type: 'string' },
size: { type: 'integer' },
},
Expand All @@ -233,6 +239,8 @@ export const openapi = {
video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' },
title: { type: 'string', minLength: 1, maxLength: 120 },
durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 },
width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 },
height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 },
},
},
Submission: {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/middlewares/validators/demoVideoSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import { z } from 'zod'
export const uploadDemoVideoSchema = z.object({
title: z.string({ error: 'Give the demo video a title.' }).trim().min(1, 'Give the demo video a title.').max(120, 'Keep the title under 120 characters.'),
durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0),
width: z.coerce.number().int().min(0).max(16384).default(0),
height: z.coerce.number().int().min(0).max(16384).default(0),
})
4 changes: 4 additions & 0 deletions backend/src/middlewares/validators/videoSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDE
// An empty string (from a form's "Unassigned" option) means no category.
const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable())

const pixels = z.coerce.number({ error: 'Size must be a number.' }).int().min(0).max(16384).default(0)

export const createVideoSchema = z.object({
title,
status: status.default(VIDEO_STATUS.PUBLISHED),
categoryId: categoryId.optional().default(null),
durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0),
width: pixels,
height: pixels,
})

export const updateVideoSchema = z.object({
Expand Down
2 changes: 2 additions & 0 deletions backend/src/models/DemoVideo.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const demoVideoSchema = new mongoose.Schema({
_id: { type: String, default: 'current' },
title: { type: String, required: true, trim: true, maxlength: 120 },
durationSec: { type: Number, default: 0, min: 0 },
width: { type: Number, default: 0, min: 0 },
height: { type: Number, default: 0, min: 0 },
videoFile: { type: storedFileSchema, required: true },
}, {
timestamps: true,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/models/Video.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const videoSchema = new mongoose.Schema({
category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null },
order: { type: Number, default: 1, min: 0 },
durationSec: { type: Number, default: 0, min: 0 },
// Displayed size in pixels as the browser reported it when the admin prepared the file; 0 means unknown (older uploads).
width: { type: Number, default: 0, min: 0 },
height: { type: Number, default: 0, min: 0 },
videoFile: { type: storedFileSchema, required: true },
posterFile: { type: storedFileSchema, default: null },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/demoVideoService.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const demoVideoService = {

try {
const current = await demoVideoRepo.find()
const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, videoFile })
const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, width: input.width, height: input.height, videoFile })
await storageService.remove(current?.videoFile)
return saved
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/videoService.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const videoService = {
}
const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1
return await videoRepo.create({
title: input.title, status: input.status, durationSec: input.durationSec,
title: input.title, status: input.status, durationSec: input.durationSec, width: input.width, height: input.height,
category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id,
})
} catch (error) {
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/api-admin-stats-contact.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})
before(async () => {
const context = getContext()
const context = await getContext()
const admin = context.admin
daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data
work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/api-admin-user-controls.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})
before(async () => {
const context = getContext()
const context = await getContext()
const category = (await context.admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data
const video = await context.admin.upload('/videos', videoForm({ title: 'First sign', categoryId: category.id, durationSec: '3' }))
assert.equal(video.status, 201, JSON.stringify(video.body))
Expand Down
9 changes: 5 additions & 4 deletions backend/tests/api-auth.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
const contributorData = { firstName: 'Sara', surname: 'Ahmed', password: 'password1' }
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})

Expand Down
8 changes: 4 additions & 4 deletions backend/tests/api-basics.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})

Expand Down
8 changes: 4 additions & 4 deletions backend/tests/api-categories.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})

Expand Down
8 changes: 4 additions & 4 deletions backend/tests/api-demo-video.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})

Expand Down
8 changes: 4 additions & 4 deletions backend/tests/api-profile-connections.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})

Expand Down
10 changes: 5 additions & 5 deletions backend/tests/api-submissions.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})
before(async () => {
const context = getContext()
const context = await getContext()
const admin = context.admin
daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data
work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/api-videos.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'
import { before, describe, test } from 'node:test'
import { after, before, describe, test } from 'node:test'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'

const getContext = useApiContext()
const getContext = useApiContext({ after })
let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries
let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo
let daily, work, video1, video2, draft, submission, eveSession

before(() => {
const context = getContext()
before(async () => {
const context = await getContext()
;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context)
})
before(async () => {
const context = getContext()
const context = await getContext()
daily = (await context.admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' })).body.data
work = (await context.admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data
})
Expand Down
Loading
Loading