feat: pharmacy hours status and open now filter
Run Tests on Branches / Detect Changes (push) Successful in 17s
Run Tests on Branches / Backend Tests (push) Successful in 2m44s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Detect Changes (push) Successful in 17s
Run Tests on Branches / Backend Tests (push) Successful in 2m44s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add is_open_now and is_always_open helpers in backend - Add backend endpoint enrichment with is_open and is_24h fields - Add client-side getOpenStatus with 24h, opens-at, opens-tomorrow support - Add open-now filter toggle in PublicView and SearchView - Add pharmacy no-hours fallback display - Add sticky pharmacy controls on scroll - Add admin hours editor with 24h toggle - Add translations (es/ca) for all hour-related strings - Add backend tests for hours logic and pharmacy endpoint - Remove unused backup test files
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { isOpenNow, isAlwaysOpen } from '../src/hours.js';
|
||||
|
||||
const HOURS_24_7 = {
|
||||
mon: ['00:00', '24:00'],
|
||||
tue: ['00:00', '24:00'],
|
||||
wed: ['00:00', '24:00'],
|
||||
thu: ['00:00', '24:00'],
|
||||
fri: ['00:00', '24:00'],
|
||||
sat: ['00:00', '24:00'],
|
||||
sun: ['00:00', '24:00'],
|
||||
};
|
||||
|
||||
const HOURS_NORMAL_WEEK = {
|
||||
mon: ['09:00', '21:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: ['09:00', '21:00'],
|
||||
thu: ['09:00', '21:00'],
|
||||
fri: ['09:00', '21:00'],
|
||||
sat: ['09:00', '14:00'],
|
||||
sun: null,
|
||||
};
|
||||
|
||||
const HOURS_ALL_CLOSED = {
|
||||
mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null,
|
||||
};
|
||||
|
||||
const HOURS_MIDNIGHT_CROSS = {
|
||||
mon: ['22:00', '02:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: null, thu: null, fri: null, sat: null, sun: null,
|
||||
};
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
describe('isAlwaysOpen', () => {
|
||||
test('all 7 days 00:00-24:00 → true', () => {
|
||||
expect(isAlwaysOpen(HOURS_24_7)).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts JSON string', () => {
|
||||
expect(isAlwaysOpen(JSON.stringify(HOURS_24_7))).toBe(true);
|
||||
});
|
||||
|
||||
test('one day different → false', () => {
|
||||
expect(isAlwaysOpen({ ...HOURS_24_7, sun: ['00:00', '23:59'] })).toBe(false);
|
||||
});
|
||||
|
||||
test('one day null → false', () => {
|
||||
expect(isAlwaysOpen({ ...HOURS_24_7, sun: null })).toBe(false);
|
||||
});
|
||||
|
||||
test('null input → false', () => {
|
||||
expect(isAlwaysOpen(null)).toBe(false);
|
||||
expect(isAlwaysOpen('')).toBe(false);
|
||||
expect(isAlwaysOpen(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('malformed JSON → false', () => {
|
||||
expect(isAlwaysOpen('{not-json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenNow', () => {
|
||||
test('null/empty input → null', () => {
|
||||
expect(isOpenNow(null)).toBeNull();
|
||||
expect(isOpenNow('')).toBeNull();
|
||||
expect(isOpenNow(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('malformed JSON → null', () => {
|
||||
expect(isOpenNow('{garbage')).toBeNull();
|
||||
});
|
||||
|
||||
test('24/7 → { isOpen: true, kind: "24h" }', () => {
|
||||
const s = isOpenNow(HOURS_24_7, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('24/7 at midnight → still 24h', () => {
|
||||
const s = isOpenNow(HOURS_24_7, at('2026-07-27T00:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('24/7 (JSON string) → kind 24h', () => {
|
||||
const s = isOpenNow(JSON.stringify(HOURS_24_7), at('2026-07-27T15:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('normal weekday at 10:00 (monday) → open, closesAt 21:00', () => {
|
||||
// 2026-07-27 is a Monday (verify with date -d)
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T10:30:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '21:00' });
|
||||
});
|
||||
|
||||
test('normal weekday at 21:30 (monday) → closed, nextOpen tuesday 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T21:30:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('after-close');
|
||||
expect(s.nextOpen).toEqual({ day: 'tue', time: '09:00' });
|
||||
});
|
||||
|
||||
test('normal weekday at 08:30 (monday) → closed before-open, opensAt 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T08:30:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('sunday with no hours → closed, nextOpen monday 09:00', () => {
|
||||
// 2026-07-26 is a Sunday
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-26T10:00:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('closed');
|
||||
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
|
||||
});
|
||||
|
||||
test('all week closed → closed, no nextOpen', () => {
|
||||
const s = isOpenNow(HOURS_ALL_CLOSED, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'closed', nextOpen: null });
|
||||
});
|
||||
|
||||
test('midnight-crossing: monday 22:00-02:00, at 23:00 → open', () => {
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T23:00:00'));
|
||||
expect(s.isOpen).toBe(true);
|
||||
expect(s.kind).toBe('open');
|
||||
expect(s.closesAt).toBe('02:00');
|
||||
});
|
||||
|
||||
test('midnight-crossing: monday 22:00-02:00, at 01:00 → open (yesterday range still active)', () => {
|
||||
// Tuesday 01:00 → it's inside monday's 22:00-02:00 range (extended past midnight)
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T01:00:00'));
|
||||
expect(s.isOpen).toBe(true);
|
||||
expect(s.kind).toBe('open');
|
||||
});
|
||||
|
||||
test('midnight-crossing: at tuesday 03:00, today opens at 09:00 → before-open', () => {
|
||||
// Tuesday 03:00 with monday 22:00-02:00 already finished; today's tue is 09:00-21:00.
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T03:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('midnight-crossing: same day before today opens → before-open with today time', () => {
|
||||
// HOURS_MIDNIGHT_CROSS: mon 22:00-02:00 (crossing). At monday 10:00, before today's 22:00.
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '22:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('saturday morning at 10:00 (with 09:00-14:00) → open, closesAt 14:00', () => {
|
||||
// 2026-07-25 is a Saturday
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '14:00' });
|
||||
});
|
||||
|
||||
test('saturday at 15:00 → closed, nextOpen monday 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T15:00:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('after-close');
|
||||
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { jest } from '@jest/globals'
|
||||
|
||||
jest.unstable_mockModule('../cima-service.js', () => ({
|
||||
searchMedicines: jest.fn(async () => []),
|
||||
getMedicineDetails: jest.fn(async () => null),
|
||||
searchOTC: jest.fn(async () => []),
|
||||
}))
|
||||
|
||||
jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({
|
||||
runFarmaciaWebhookImport: jest.fn(async () => ({})),
|
||||
DEFAULT_FARMACIAS_WEBHOOK: '',
|
||||
importPharmaciesFromRows: jest.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
jest.unstable_mockModule('../../API/index.js', () => ({
|
||||
fetchPharmaciesExternal: jest.fn(async () => []),
|
||||
}))
|
||||
|
||||
process.env.DATABASE_PATH = ':memory:'
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
const { default: supertest } = await import('supertest')
|
||||
const { app, initDatabase, db } = await import('../server.js')
|
||||
|
||||
const HOURS_24_7 = JSON.stringify({
|
||||
mon: ['00:00', '24:00'],
|
||||
tue: ['00:00', '24:00'],
|
||||
wed: ['00:00', '24:00'],
|
||||
thu: ['00:00', '24:00'],
|
||||
fri: ['00:00', '24:00'],
|
||||
sat: ['00:00', '24:00'],
|
||||
sun: ['00:00', '24:00'],
|
||||
})
|
||||
|
||||
const HOURS_NORMAL = JSON.stringify({
|
||||
mon: ['09:00', '21:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: ['09:00', '21:00'],
|
||||
thu: ['09:00', '21:00'],
|
||||
fri: ['09:00', '21:00'],
|
||||
sat: ['09:00', '14:00'],
|
||||
sun: null,
|
||||
})
|
||||
|
||||
function insertPharmacy(name, openingHours) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'INSERT INTO pharmacies (name, address, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?)',
|
||||
[name, 'Address of ' + name, 41.5, 2.0, openingHours],
|
||||
function (err) { return err ? reject(err) : resolve(this.lastID) }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run('DELETE FROM pharmacies', (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/pharmacies — is_open / is_24h enrichment', () => {
|
||||
test('24/7 pharmacy → is_open=true, is_24h=true', async () => {
|
||||
await insertPharmacy('24h Pharmacy', HOURS_24_7)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.length).toBe(1)
|
||||
expect(res.body[0].is_open).toBe(true)
|
||||
expect(res.body[0].is_24h).toBe(true)
|
||||
})
|
||||
|
||||
test('normal hours pharmacy at an open time → is_open=true, is_24h=false', async () => {
|
||||
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body[0].is_24h).toBe(false)
|
||||
expect(typeof res.body[0].is_open).toBe('boolean')
|
||||
})
|
||||
|
||||
test('null opening_hours → is_open=null, is_24h=false', async () => {
|
||||
await insertPharmacy('No Hours', null)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body[0].is_open).toBeNull()
|
||||
expect(res.body[0].is_24h).toBe(false)
|
||||
})
|
||||
|
||||
test('multiple pharmacies each get correct enrichment', async () => {
|
||||
await insertPharmacy('24h Pharmacy', HOURS_24_7)
|
||||
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
|
||||
await insertPharmacy('No Hours', null)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.length).toBe(3)
|
||||
const byName = Object.fromEntries(res.body.map(p => [p.name, p]))
|
||||
expect(byName['24h Pharmacy'].is_open).toBe(true)
|
||||
expect(byName['24h Pharmacy'].is_24h).toBe(true)
|
||||
expect(byName['Normal Pharmacy'].is_24h).toBe(false)
|
||||
expect(byName['No Hours'].is_open).toBeNull()
|
||||
expect(byName['No Hours'].is_24h).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user