Skip to content
Open
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
50 changes: 40 additions & 10 deletions backend/__tests__/unit/routes/uploads.post.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
// ADR-002 Phase 1 — POST route covers driver.put + metadata-only File save,
// plus the new size-cap rejection branch.

const request = require('supertest');
const express = require('express');

const mockStore = {
capabilities: { name: 'mongo', maxObjectBytes: 10 * 1024 * 1024 },
get: jest.fn(),
put: jest.fn().mockResolvedValue(undefined),
delete: jest.fn(),
};

jest.mock('../../../services/objectStore', () => ({
getObjectStore: () => mockStore,
__resetObjectStoreForTests: jest.fn(),
}));

jest.mock('../../../models/File', () => {
const saveMock = jest.fn();
const File = jest
.fn()
.mockImplementation((data) => ({ ...data, save: saveMock }));
const saveMock = jest.fn().mockResolvedValue(undefined);
const File = jest.fn().mockImplementation((data) => ({ ...data, save: saveMock }));
File.findByFileName = jest.fn();
File.__saveMock = saveMock;
return File;
Expand All @@ -19,34 +32,51 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => {

const routes = require('../../../routes/uploads');

describe('uploads POST route', () => {
describe('uploads POST / (ADR-002 Phase 1)', () => {
let app;

beforeEach(() => {
app = express();
app.use(express.json());
app.use('/api/uploads', routes);
File.__saveMock.mockReset();
File.__saveMock.mockClear();
File.mockClear();
mockStore.put.mockClear();
});

it('uploads image and saves file', async () => {
it('writes bytes through the driver and saves metadata-only File', async () => {
await request(app)
.post('/api/uploads')
.attach('image', Buffer.from('data'), 'photo.png')
.expect(200);

// Driver received the bytes + mime
expect(mockStore.put).toHaveBeenCalledWith(
expect.stringMatching(/\.png$/),
expect.any(Buffer),
'image/png',
);
// File was created with metadata only — no `data` field
expect(File).toHaveBeenCalled();
const fileArgs = File.mock.calls[0][0];
expect(fileArgs.data).toBeUndefined();
expect(fileArgs.fileName).toMatch(/\.png$/);
expect(fileArgs.contentType).toBe('image/png');
expect(fileArgs.uploadedBy).toBe('user1');
expect(File.__saveMock).toHaveBeenCalled();
});

it('returns 400 when no file provided', async () => {
it('returns 400 when no file is provided', async () => {
const res = await request(app).post('/api/uploads').expect(400);
expect(res.body.msg).toBe('No file uploaded');
expect(mockStore.put).not.toHaveBeenCalled();
});

it('rejects invalid file types', async () => {
it('rejects disallowed extensions before reaching the driver', async () => {
await request(app)
.post('/api/uploads')
.attach('image', Buffer.from('data'), 'text.txt')
.expect(500);
.expect(500); // multer surfaces the filter error as 500
expect(mockStore.put).not.toHaveBeenCalled();
});
});
85 changes: 73 additions & 12 deletions backend/__tests__/unit/routes/uploads.test.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,94 @@
// ADR-002 Phase 1 — GET route covers both the driver-hit path and the
// legacy File.data fallback for pre-ADR-002 records.

const request = require('supertest');
const express = require('express');
const { Readable } = require('stream');

const mockStore = {
capabilities: { name: 'mongo', maxObjectBytes: 10 * 1024 * 1024 },
get: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};

jest.mock('../../../services/objectStore', () => ({
getObjectStore: () => mockStore,
__resetObjectStoreForTests: jest.fn(),
}));

jest.mock('../../../models/File', () => ({
findByFileName: jest
.fn()
.mockResolvedValue({ data: Buffer.from('x'), contentType: 'text/plain' }),
findByFileName: jest.fn(),
}));

jest.mock('../../../middleware/auth', () => (req, res, next) => next());

const routes = require('../../../routes/uploads');
const File = require('../../../models/File');
const routes = require('../../../routes/uploads');

describe('uploads routes', () => {
describe('uploads GET /:fileName (ADR-002 Phase 1)', () => {
const app = express();
app.use(express.json());
app.use('/api/uploads', routes);

it('GET /api/uploads/:file calls findByFileName', async () => {
await request(app).get('/api/uploads/test').expect(200);
expect(File.findByFileName).toHaveBeenCalledWith('test');
beforeEach(() => {
mockStore.get.mockReset();
mockStore.put.mockReset();
mockStore.delete.mockReset();
File.findByFileName.mockReset();
});
it('returns 404 when file not found', async () => {

it('streams bytes from the driver when the key is present', async () => {
mockStore.get.mockResolvedValue({
stream: Readable.from(Buffer.from('hello')),
mime: 'image/png',
size: 5,
});

const res = await request(app)
.get('/api/uploads/new.png')
.buffer(true)
.parse((r, cb) => {
const chunks = [];
r.on('data', (c) => chunks.push(c));
r.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
expect(res.headers['content-type']).toMatch(/image\/png/);
expect(Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body)).toEqual(
Buffer.from('hello'),
);
expect(mockStore.get).toHaveBeenCalledWith('new.png');
expect(File.findByFileName).not.toHaveBeenCalled();
});

it('falls back to legacy File.data when the driver returns null', async () => {
mockStore.get.mockResolvedValue(null);
File.findByFileName.mockResolvedValue({
data: Buffer.from('legacy-bytes'),
contentType: 'image/jpeg',
});

const res = await request(app).get('/api/uploads/legacy.jpg').expect(200);
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
expect(res.body.toString()).toBe('legacy-bytes');
expect(File.findByFileName).toHaveBeenCalledWith('legacy.jpg');
});

it('returns 404 when neither driver nor legacy store has the key', async () => {
mockStore.get.mockResolvedValue(null);
File.findByFileName.mockResolvedValue(null);
await request(app).get('/api/uploads/missing').expect(404);
});

it('returns 500 on error', async () => {
File.findByFileName.mockRejectedValue(new Error('fail'));
await request(app).get('/api/uploads/oops').expect(500);
it('returns 404 when the legacy record exists but has no data (metadata-only)', async () => {
mockStore.get.mockResolvedValue(null);
File.findByFileName.mockResolvedValue({ data: Buffer.alloc(0), contentType: 'image/png' });
await request(app).get('/api/uploads/empty.png').expect(404);
});

it('returns 500 when the driver throws', async () => {
mockStore.get.mockRejectedValue(new Error('boom'));
await request(app).get('/api/uploads/explode').expect(500);
});
});
35 changes: 35 additions & 0 deletions backend/__tests__/unit/services/objectStore/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @ts-nocheck
// ADR-002 Phase 1: driver selection via OBJECT_STORE_DRIVER env.

const { getObjectStore, __resetObjectStoreForTests } = require('../../../../services/objectStore');

describe('getObjectStore (ADR-002 Phase 1 driver selection)', () => {
const ORIGINAL_ENV = process.env;

afterEach(() => {
process.env = ORIGINAL_ENV;
__resetObjectStoreForTests();
});

beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.OBJECT_STORE_DRIVER;
__resetObjectStoreForTests();
});

it('defaults to the mongo driver when OBJECT_STORE_DRIVER is unset', () => {
const store = getObjectStore();
expect(store.capabilities.name).toBe('mongo');
});

it('rejects unknown drivers with a clear error', () => {
process.env.OBJECT_STORE_DRIVER = 'unicorn';
expect(() => getObjectStore()).toThrow(/unicorn.*not supported/);
});

it('caches the resolved driver across calls', () => {
const a = getObjectStore();
const b = getObjectStore();
expect(a).toBe(b);
});
});
72 changes: 72 additions & 0 deletions backend/__tests__/unit/services/objectStore/mongoDriver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @ts-nocheck
// ADR-002 Phase 1: MongoObjectStore driver contract.

const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../../utils/testUtils');
const { MongoObjectStore } = require('../../../../services/objectStore/drivers/mongoDriver');

async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
return Buffer.concat(chunks);
}

describe('MongoObjectStore (ADR-002 Phase 1)', () => {
let store;

beforeAll(async () => {
await setupMongoDb();
});
afterAll(async () => {
await closeMongoDb();
});
afterEach(async () => {
await clearMongoDb();
});

beforeEach(() => {
store = new MongoObjectStore();
});

it('exposes driver name and a default max size', () => {
expect(store.capabilities.name).toBe('mongo');
expect(store.capabilities.maxObjectBytes).toBeGreaterThan(0);
});

it('round-trips bytes and mime through put/get', async () => {
const body = Buffer.from('hello-commonly');
await store.put('k1.jpg', body, 'image/jpeg');

const got = await store.get('k1.jpg');
expect(got).not.toBeNull();
expect(got.mime).toBe('image/jpeg');
expect(got.size).toBe(body.length);
const bytes = await streamToBuffer(got.stream);
expect(bytes.equals(body)).toBe(true);
});

it('returns null for a missing key', async () => {
const got = await store.get('does-not-exist.png');
expect(got).toBeNull();
});

it('put is idempotent per key (overwrite)', async () => {
await store.put('same.png', Buffer.from('v1'), 'image/png');
await store.put('same.png', Buffer.from('v2-longer'), 'image/png');

const got = await store.get('same.png');
const bytes = await streamToBuffer(got.stream);
expect(bytes.toString()).toBe('v2-longer');
expect(got.size).toBe(9);
});

it('delete removes a previously stored key', async () => {
await store.put('to-delete.gif', Buffer.from('bye'), 'image/gif');
await store.delete('to-delete.gif');
const got = await store.get('to-delete.gif');
expect(got).toBeNull();
});

it('delete is a no-op for a missing key', async () => {
await expect(store.delete('never-existed')).resolves.toBeUndefined();
});
});
10 changes: 8 additions & 2 deletions backend/models/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ export interface IFile extends Document {
originalName: string;
contentType: string;
size: number;
data: Buffer;
/**
* Legacy inline byte storage. For records created before ADR-002 Phase 1
* this holds the full payload. New records leave it empty — bytes live in
* the configured ObjectStore driver, keyed by `fileName`. Phase 2 removes
* this field entirely after backfilling legacy records.
*/
data?: Buffer;
uploadedBy: Types.ObjectId;
createdAt: Date;
}
Expand All @@ -19,7 +25,7 @@ const fileSchema = new Schema<IFile>({
originalName: { type: String, required: true },
contentType: { type: String, required: true },
size: { type: Number, required: true },
data: { type: Buffer, required: true },
data: { type: Buffer, required: false },
uploadedBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
createdAt: { type: Date, default: Date.now },
});
Expand Down
44 changes: 44 additions & 0 deletions backend/models/MediaObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* MediaObject — byte blob + the small byte-level metadata (`mime`, `size`)
* any driver needs to serve it back. Owned by the Mongo ObjectStore driver
* (ADR-002 Phase 1). Display/ownership metadata (uploadedBy, originalName)
* stays on `File` in Phase 1 and moves to `Attachment` in Phase 2.
*
* Why a new collection instead of reusing `File`: the ObjectStore interface
* is driver-agnostic. Coupling the Mongo driver to a schema that also carries
* display metadata would leak those concerns across every driver (gcs, s3,
* etc.), contradicting "bytes live in the driver, metadata on the parent
* entity" from REVIEW.md §Attachments.
*/

import mongoose, { Document, Model, Schema } from 'mongoose';

export interface IMediaObject extends Document {
key: string;
data: Buffer;
mime: string;
size: number;
createdAt: Date;
}

export interface IMediaObjectModel extends Model<IMediaObject> {
findByKey(key: string): mongoose.Query<IMediaObject | null, IMediaObject>;
}

const mediaObjectSchema = new Schema<IMediaObject>({
key: { type: String, required: true, unique: true },
data: { type: Buffer, required: true },
mime: { type: String, required: true },
size: { type: Number, required: true },
createdAt: { type: Date, default: Date.now },
});

mediaObjectSchema.statics.findByKey = function (key: string) {
return this.findOne({ key });
};

export default mongoose.model<IMediaObject, IMediaObjectModel>('MediaObject', mediaObjectSchema);
// CJS compat: let require() return the default export directly
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = exports['default'];
Object.assign(module.exports, exports);
Loading
Loading