diff --git a/src/db/file/repo.ts b/src/db/file/repo.ts index 76cc8be9b..4cc3885de 100644 --- a/src/db/file/repo.ts +++ b/src/db/file/repo.ts @@ -106,6 +106,10 @@ export const getRepoById = async (_id: string): Promise => { }; export const createRepo = async (repo: Repo): Promise => { + const now = new Date().toISOString(); + if (!repo.dateCreated) repo.dateCreated = now; + if (!repo.lastModified) repo.lastModified = now; + return new Promise((resolve, reject) => { db.insert(repo, (err, doc) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -130,6 +134,7 @@ export const addUserCanPush = async (_id: string, user: string): Promise = return; } repo.users?.canPush.push(user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; @@ -158,6 +163,7 @@ export const addUserCanAuthorise = async (_id: string, user: string): Promise x != user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; @@ -206,6 +213,7 @@ export const removeUserCanPush = async (_id: string, user: string): Promise x != user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; diff --git a/src/db/index.ts b/src/db/index.ts index 8c6a19c72..bb21c7561 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -111,12 +111,15 @@ export const createUser = async ( }; export const createRepo = async (repo: AuthorisedRepo) => { + const now = new Date().toISOString(); const toCreate = { ...repo, users: { canPush: [], canAuthorise: [], }, + dateCreated: now, + lastModified: now, }; toCreate.name = repo.name.toLowerCase(); diff --git a/src/db/mongo/repo.ts b/src/db/mongo/repo.ts index b2ec4f4df..8019c89db 100644 --- a/src/db/mongo/repo.ts +++ b/src/db/mongo/repo.ts @@ -48,6 +48,10 @@ export const getRepoById = async (_id: string): Promise => { }; export const createRepo = async (repo: Repo): Promise => { + const now = new Date().toISOString(); + if (!repo.dateCreated) repo.dateCreated = now; + if (!repo.lastModified) repo.lastModified = now; + const collection = await connect(collectionName); const response = await collection.insertOne(repo as OptionalId); console.log(`created new repo ${JSON.stringify(repo)}`); @@ -59,25 +63,37 @@ export const createRepo = async (repo: Repo): Promise => { export const addUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canPush': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $push: { 'users.canPush': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const addUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canAuthorise': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $push: { 'users.canAuthorise': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const removeUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canPush': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $pull: { 'users.canPush': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const removeUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canAuthorise': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $pull: { 'users.canAuthorise': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const deleteRepo = async (_id: string): Promise => { diff --git a/src/db/types.ts b/src/db/types.ts index 495dd98b2..8624ae196 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -58,6 +58,19 @@ export class Repo { name: string; url: string; users: { canPush: string[]; canAuthorise: string[] }; + /** + * ISO-8601; set on create, never overwritten thereafter. + * Existing repos missing this field are intentionally left unset here — + * backfill belongs in a follow-up versioned migration (Mongo: prefer + * `$toDate: "$_id"` over an epoch default). Do not reintroduce startup + * one-off backfills. + */ + dateCreated?: string; + /** + * ISO-8601; set on create and bumped on repo metadata mutations. + * Same migration note as {@link Repo.dateCreated}. + */ + lastModified?: string; _id?: string; constructor( @@ -66,12 +79,16 @@ export class Repo { url: string, users?: Record, _id?: string, + dateCreated?: string, + lastModified?: string, ) { this.project = project; this.name = name; this.url = url; this.users = users ?? { canPush: [], canAuthorise: [] }; this._id = _id; + this.dateCreated = dateCreated; + this.lastModified = lastModified; } } diff --git a/test/db/mongo/repo.test.ts b/test/db/mongo/repo.test.ts index 61e9d882c..a240c7e93 100644 --- a/test/db/mongo/repo.test.ts +++ b/test/db/mongo/repo.test.ts @@ -215,6 +215,8 @@ describe('MongoDB Repo', async () => { expect(mockInsertOne).toHaveBeenCalledWith(newRepo); expect(result._id).toBe(insertedId.toString()); expect(result.name).toBe('new-repo'); + expect(result.dateCreated).toEqual(expect.any(String)); + expect(result.lastModified).toEqual(expect.any(String)); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); @@ -230,7 +232,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canPush': 'newuser' } }, + { + $push: { 'users.canPush': 'newuser' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -241,7 +246,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canPush': 'uppercase' } }, + { + $push: { 'users.canPush': 'uppercase' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -255,7 +263,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canAuthorise': 'newadmin' } }, + { + $push: { 'users.canAuthorise': 'newadmin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -266,7 +277,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canAuthorise': 'admin' } }, + { + $push: { 'users.canAuthorise': 'admin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -280,7 +294,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canPush': 'user1' } }, + { + $pull: { 'users.canPush': 'user1' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -291,7 +308,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canPush': 'user' } }, + { + $pull: { 'users.canPush': 'user' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -305,7 +325,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canAuthorise': 'admin1' } }, + { + $pull: { 'users.canAuthorise': 'admin1' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -316,7 +339,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canAuthorise': 'admin' } }, + { + $pull: { 'users.canAuthorise': 'admin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); diff --git a/test/db/repo.date-fields.test.ts b/test/db/repo.date-fields.test.ts new file mode 100644 index 000000000..82354ddfb --- /dev/null +++ b/test/db/repo.date-fields.test.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as repoModule from '../../src/db/file/repo'; +import { Repo } from '../../src/db/types'; + +describe('Repo dateCreated / lastModified (#1486)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('createRepo persists dateCreated and lastModified as ISO-8601', async () => { + const inserted: Repo[] = []; + vi.spyOn(repoModule.db, 'insert').mockImplementation((doc: unknown, cb: any) => { + const stored = { ...(doc as Repo), _id: 'new-id' }; + inserted.push(stored); + cb(null, stored); + }); + + const before = Date.now(); + const result = await repoModule.createRepo( + new Repo('finos', 'sample', 'https://github.com/finos/sample.git'), + ); + const after = Date.now(); + + expect(result.dateCreated).toEqual(expect.any(String)); + expect(result.lastModified).toEqual(expect.any(String)); + expect(Date.parse(result.dateCreated!)).toBeGreaterThanOrEqual(before); + expect(Date.parse(result.dateCreated!)).toBeLessThanOrEqual(after); + expect(result.dateCreated).toBe(result.lastModified); + expect(inserted[0].dateCreated).toBe(result.dateCreated); + }); + + it('addUserCanPush bumps lastModified but leaves dateCreated unchanged', async () => { + const existing: Repo = { + project: 'finos', + name: 'sample', + url: 'https://github.com/finos/sample.git', + users: { canPush: [], canAuthorise: [] }, + dateCreated: '2020-01-01T00:00:00.000Z', + lastModified: '2020-01-01T00:00:00.000Z', + _id: 'abc', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((_: unknown, cb: any) => + cb(null, { ...existing }), + ); + let updatedDoc: Repo | null = null; + vi.spyOn(repoModule.db, 'update').mockImplementation( + (_q: unknown, doc: any, _o: unknown, cb: any) => { + updatedDoc = doc; + cb(null, 1); + }, + ); + + await repoModule.addUserCanPush('abc', 'alice'); + + expect(updatedDoc!.dateCreated).toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.lastModified).not.toBe('2020-01-01T00:00:00.000Z'); + expect(Date.parse(updatedDoc!.lastModified!)).toBeGreaterThan( + Date.parse('2020-01-01T00:00:00.000Z'), + ); + expect(updatedDoc!.users.canPush).toContain('alice'); + }); + + it('removeUserCanAuthorise bumps lastModified but leaves dateCreated unchanged', async () => { + const existing: Repo = { + project: 'finos', + name: 'sample', + url: 'https://github.com/finos/sample.git', + users: { canPush: [], canAuthorise: ['bob'] }, + dateCreated: '2020-01-01T00:00:00.000Z', + lastModified: '2020-01-01T00:00:00.000Z', + _id: 'abc', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((_: unknown, cb: any) => + cb(null, { ...existing }), + ); + let updatedDoc: Repo | null = null; + vi.spyOn(repoModule.db, 'update').mockImplementation( + (_q: unknown, doc: any, _o: unknown, cb: any) => { + updatedDoc = doc; + cb(null, 1); + }, + ); + + await repoModule.removeUserCanAuthorise('abc', 'bob'); + + expect(updatedDoc!.dateCreated).toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.lastModified).not.toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.users.canAuthorise).not.toContain('bob'); + }); + + it('sort by dateCreated orders oldest → newest (asc)', () => { + const repos: Pick[] = [ + { name: 'zeta', dateCreated: '2024-06-01T00:00:00.000Z' }, + { name: 'alpha', dateCreated: '2023-01-01T00:00:00.000Z' }, + { name: 'mid', dateCreated: '2023-12-01T00:00:00.000Z' }, + ]; + + const sorted = [...repos].sort( + (a, b) => new Date(a.dateCreated || 0).getTime() - new Date(b.dateCreated || 0).getTime(), + ); + + expect(sorted.map((r) => r.name)).toEqual(['alpha', 'mid', 'zeta']); + }); + + it('sort by lastModified orders oldest → newest (asc)', () => { + const repos: Pick[] = [ + { name: 'fresh', lastModified: '2025-01-01T00:00:00.000Z' }, + { name: 'stale', lastModified: '2022-01-01T00:00:00.000Z' }, + { name: 'mid', lastModified: '2024-01-01T00:00:00.000Z' }, + ]; + + const sorted = [...repos].sort( + (a, b) => new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(), + ); + + expect(sorted.map((r) => r.name)).toEqual(['stale', 'mid', 'fresh']); + }); +});