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
92 changes: 92 additions & 0 deletions backend/src/__tests__/routes/workEntries.date.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const request = require('supertest');
const express = require('express');
const workEntryRoutes = require('../../routes/workEntries');
const { getDatabase } = require('../../database/init');

jest.mock('../../database/init');
jest.mock('../../middleware/auth', () => ({
authenticateUser: (req, res, next) => {
req.userEmail = 'test@example.com';
next();
}
}));

const app = express();
app.use(express.json());
app.use('/api/work-entries', workEntryRoutes);
// Add error handler for Joi validation
app.use((err, req, res, next) => {
if (err.isJoi) {
return res.status(400).json({ error: 'Validation error' });
}
res.status(500).json({ error: 'Internal server error' });
});

describe('Work Entry Routes - Date Persistence', () => {
let mockDb;
let consoleErrorSpy;

beforeEach(() => {
mockDb = {
all: jest.fn(),
get: jest.fn(),
run: jest.fn()
};
getDatabase.mockReturnValue(mockDb);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});

afterEach(() => {
consoleErrorSpy.mockRestore();
jest.clearAllMocks();
});

const mockWriteSucceeds = () => {
mockDb.get
.mockImplementationOnce((query, params, callback) => callback(null, { id: 1 }))
.mockImplementationOnce((query, params, callback) => callback(null, { id: 10 }));
mockDb.run.mockImplementation(function (query, params, callback) {
callback.call({ lastID: 10, changes: 1 }, null);
});
};

test('POST should store the date as a YYYY-MM-DD string, not epoch milliseconds', async () => {
mockWriteSucceeds();

const response = await request(app)
.post('/api/work-entries')
.send({ clientId: 1, hours: 7.5, date: '2024-03-09' });

expect(response.status).toBe(201);
const [, params] = mockDb.run.mock.calls[0];
expect(params[4]).toBe('2024-03-09');
});

test('POST should truncate a full ISO timestamp to the calendar date', async () => {
mockWriteSucceeds();

await request(app)
.post('/api/work-entries')
.send({ clientId: 1, hours: 1, date: '2024-03-09T18:30:00.000Z' });

const [, params] = mockDb.run.mock.calls[0];
expect(params[4]).toBe('2024-03-09');
});

test('PUT should store an updated date as a YYYY-MM-DD string', async () => {
mockDb.get
.mockImplementationOnce((query, params, callback) => callback(null, { id: 5 }))
.mockImplementationOnce((query, params, callback) => callback(null, { id: 5 }));
mockDb.run.mockImplementation(function (query, params, callback) {
callback.call({ changes: 1 }, null);
});

const response = await request(app)
.put('/api/work-entries/5')
.send({ date: '2024-03-09' });

expect(response.status).toBe(200);
const [, values] = mockDb.run.mock.calls[0];
expect(values[0]).toBe('2024-03-09');
});
});
32 changes: 32 additions & 0 deletions backend/src/__tests__/utils/date.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const { toDateOnly } = require('../../utils/date');

describe('toDateOnly', () => {
test('should convert a Date to a YYYY-MM-DD string', () => {
expect(toDateOnly(new Date('2024-01-05T00:00:00.000Z'))).toBe('2024-01-05');
});

test('should drop the time component of a Date', () => {
expect(toDateOnly(new Date('2024-01-05T23:59:59.999Z'))).toBe('2024-01-05');
});

test('should pass through a string unchanged', () => {
expect(toDateOnly('2024-01-05')).toBe('2024-01-05');
});

test('should pass through an epoch-millisecond number unchanged', () => {
expect(toDateOnly(1786406400000)).toBe(1786406400000);
});

test('should truncate a Date based on UTC, not local time', () => {
// 2024-01-05T23:30 in a +05:30 offset is still 2024-01-05 18:00 UTC,
// so the UTC calendar date must be returned regardless of runner timezone.
expect(toDateOnly(new Date('2024-01-05T23:30:00.000+05:30'))).toBe('2024-01-05');
});

test.each([
['null', null],
['undefined', undefined]
])('should pass through %s unchanged', (_label, value) => {
expect(toDateOnly(value)).toBe(value);
});
});
5 changes: 3 additions & 2 deletions backend/src/routes/workEntries.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const express = require('express');
const { getDatabase } = require('../database/init');
const { authenticateUser } = require('../middleware/auth');
const { workEntrySchema, updateWorkEntrySchema } = require('../validation/schemas');
const { toDateOnly } = require('../utils/date');

const router = express.Router();

Expand Down Expand Up @@ -104,7 +105,7 @@ router.post('/', (req, res, next) => {
// Create work entry
db.run(
'INSERT INTO work_entries (client_id, user_email, hours, description, date) VALUES (?, ?, ?, ?, ?)',
[clientId, req.userEmail, hours, description || null, date],
[clientId, req.userEmail, hours, description || null, toDateOnly(date)],
function(err) {
if (err) {
console.error('Database error:', err);
Expand Down Expand Up @@ -214,7 +215,7 @@ router.put('/:id', (req, res, next) => {

if (value.date !== undefined) {
updates.push('date = ?');
values.push(value.date);
values.push(toDateOnly(value.date));
}

updates.push('updated_at = CURRENT_TIMESTAMP');
Expand Down
14 changes: 14 additions & 0 deletions backend/src/utils/date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Joi coerces validated `date` fields into JS Date objects, which the sqlite3
// driver binds as epoch milliseconds. Work entry dates are day-granular, so
// normalize them to a YYYY-MM-DD string before they reach the database.
function toDateOnly(value) {
if (value instanceof Date) {
return value.toISOString().split('T')[0];
}

return value;
}

module.exports = {
toDateOnly
};