fix: sync deletion propagation, add base64 util, timeout, and merge improvements
Critical: - Add deleted column: deleteMeasurement now soft-deletes, merge filters out deleted items based on updatedAt (was: deletions were resurrected) - Replace btoa with UTF-8-safe encodeBase64 utility for Hermes compat Important: - Preserve createdAt when overwriting duplicate-date measurements - Add 15s AbortController timeout to all WebDAV fetch calls - Revert broken date prefilling regression (MeasurementForm) - getAllMeasurements/latest/byDate now filter deleted records - Replace Dimensions with useWindowDimensions for rotation awareness - Replace silent catch with console.warn in HistoryScreen Tests: 8 new tests for deletion merge cases and soft-delete queries
This commit is contained in:
parent
c499f7df13
commit
0513db46ac
10 changed files with 207 additions and 58 deletions
|
|
@ -72,6 +72,7 @@ export default function MeasurementForm({ onSubmit, initialValues }: Props) {
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
|
deleted: 0,
|
||||||
}
|
}
|
||||||
onSubmit(m)
|
onSubmit(m)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { View, Text, StyleSheet, Dimensions } from 'react-native'
|
import { View, Text, StyleSheet, useWindowDimensions } from 'react-native'
|
||||||
import { LineChart } from 'react-native-chart-kit'
|
import { LineChart } from 'react-native-chart-kit'
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
|
|
||||||
|
|
@ -24,12 +24,13 @@ function SingleChart({
|
||||||
color: string
|
color: string
|
||||||
unit: string
|
unit: string
|
||||||
}) {
|
}) {
|
||||||
|
const { width } = useWindowDimensions()
|
||||||
return (
|
return (
|
||||||
<View style={styles.chartBlock}>
|
<View style={styles.chartBlock}>
|
||||||
<Text style={styles.chartTitle}>{title}</Text>
|
<Text style={styles.chartTitle}>{title}</Text>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
data={{ labels, datasets: [{ data, color: () => color, strokeWidth: 2 }] }}
|
||||||
width={Dimensions.get('window').width - 32}
|
width={width - 32}
|
||||||
height={180}
|
height={180}
|
||||||
yAxisSuffix={unit}
|
yAxisSuffix={unit}
|
||||||
chartConfig={{
|
chartConfig={{
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,36 @@ export async function initDatabase(): Promise<void> {
|
||||||
measuredAt TEXT NOT NULL,
|
measuredAt TEXT NOT NULL,
|
||||||
createdAt TEXT NOT NULL,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL,
|
updatedAt TEXT NOT NULL,
|
||||||
syncStatus TEXT NOT NULL DEFAULT 'local'
|
syncStatus TEXT NOT NULL DEFAULT 'local',
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
await db.execAsync(`ALTER TABLE measurements ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertMeasurement(m: Measurement): Promise<void> {
|
export async function insertMeasurement(m: Measurement): Promise<void> {
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
`INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, 0]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeOnly(): string {
|
||||||
|
return 'deleted = 0'
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllMeasurements(): Promise<Measurement[]> {
|
export async function getAllMeasurements(): Promise<Measurement[]> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC`
|
||||||
)
|
)
|
||||||
return rows as Measurement[]
|
return rows as Measurement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
'SELECT * FROM measurements ORDER BY measuredAt DESC LIMIT 1'
|
`SELECT * FROM measurements WHERE ${activeOnly()} ORDER BY measuredAt DESC LIMIT 1`
|
||||||
)
|
)
|
||||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +57,7 @@ export async function getLatestMeasurement(): Promise<Measurement | null> {
|
||||||
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
export async function getMeasurementByDate(date: string): Promise<Measurement | null> {
|
||||||
const day = date.slice(0, 10)
|
const day = date.slice(0, 10)
|
||||||
const rows = await db!.getAllAsync<any>(
|
const rows = await db!.getAllAsync<any>(
|
||||||
"SELECT * FROM measurements WHERE measuredAt LIKE ? LIMIT 1",
|
`SELECT * FROM measurements WHERE ${activeOnly()} AND measuredAt LIKE ? LIMIT 1`,
|
||||||
[`${day}%`]
|
[`${day}%`]
|
||||||
)
|
)
|
||||||
return rows.length > 0 ? (rows[0] as Measurement) : null
|
return rows.length > 0 ? (rows[0] as Measurement) : null
|
||||||
|
|
@ -65,9 +72,9 @@ export async function updateMeasurement(id: string, syncStatus: SyncStatus): Pro
|
||||||
|
|
||||||
export async function upsertMeasurement(m: Measurement): Promise<void> {
|
export async function upsertMeasurement(m: Measurement): Promise<void> {
|
||||||
await db!.runAsync(
|
await db!.runAsync(
|
||||||
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus)
|
`INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deleted)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus]
|
[m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deleted]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,5 +86,15 @@ export async function getUnsyncedMeasurements(): Promise<Measurement[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMeasurement(id: string): Promise<void> {
|
export async function deleteMeasurement(id: string): Promise<void> {
|
||||||
await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id])
|
await db!.runAsync(
|
||||||
|
"UPDATE measurements SET deleted = 1, syncStatus = 'local', updatedAt = ? WHERE id = ?",
|
||||||
|
[new Date().toISOString(), id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllMeasurementsIncludingDeleted(): Promise<Measurement[]> {
|
||||||
|
const rows = await db!.getAllAsync<any>(
|
||||||
|
'SELECT * FROM measurements ORDER BY measuredAt DESC'
|
||||||
|
)
|
||||||
|
return rows as Measurement[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ export default function HistoryScreen({ navigation }: Props) {
|
||||||
try {
|
try {
|
||||||
const { syncAll } = await import('../sync/syncService')
|
const { syncAll } = await import('../sync/syncService')
|
||||||
await syncAll()
|
await syncAll()
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
console.warn('Sync failed after delete:', e)
|
||||||
|
}
|
||||||
load()
|
load()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
`Er bestaat al een meting van ${new Date(m.measuredAt).toLocaleDateString('nl-NL')}. Overschrijven?`,
|
||||||
[
|
[
|
||||||
{ text: 'Annuleren', style: 'cancel' },
|
{ text: 'Annuleren', style: 'cancel' },
|
||||||
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing.id) },
|
{ text: 'Overschrijven', style: 'destructive', onPress: () => doSave(m, existing) },
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -74,9 +74,10 @@ export default function NewMeasurementScreen({ navigation, route }: Props) {
|
||||||
doSave(m)
|
doSave(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSave = async (m: Measurement, existingId?: string) => {
|
const doSave = async (m: Measurement, existing?: Measurement) => {
|
||||||
if (existingId) {
|
if (existing) {
|
||||||
m.id = existingId
|
m.id = existing.id
|
||||||
|
m.createdAt = existing.createdAt
|
||||||
await upsertMeasurement(m)
|
await upsertMeasurement(m)
|
||||||
} else {
|
} else {
|
||||||
await insertMeasurement(m)
|
await insertMeasurement(m)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Measurement } from '../types'
|
import { Measurement } from '../types'
|
||||||
import { initDatabase, getAllMeasurements, upsertMeasurement } from '../database/db'
|
import { initDatabase, getAllMeasurementsIncludingDeleted, upsertMeasurement } from '../database/db'
|
||||||
import { getWebDAVConfig } from '../screens/SettingsScreen'
|
import { getWebDAVConfig } from '../screens/SettingsScreen'
|
||||||
|
import { encodeBase64 } from '../utils/base64'
|
||||||
|
|
||||||
const WEBDAV_PATH = 'weegschaal/measurements.json'
|
const WEBDAV_PATH = 'weegschaal/measurements.json'
|
||||||
|
const SYNC_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
type SyncResult = { local: number; remote: number; merged: number }
|
type SyncResult = { local: number; remote: number; merged: number }
|
||||||
|
|
||||||
|
|
@ -15,46 +17,66 @@ export function mergeMeasurements(local: Measurement[], remote: Measurement[]):
|
||||||
|
|
||||||
for (const m of remote) {
|
for (const m of remote) {
|
||||||
const existing = map.get(m.id)
|
const existing = map.get(m.id)
|
||||||
if (!existing || new Date(m.updatedAt) > new Date(existing.updatedAt)) {
|
if (!existing) {
|
||||||
map.set(m.id, { ...m, syncStatus: 'synced' })
|
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||||
|
} else {
|
||||||
|
const remoteNewer = new Date(m.updatedAt) > new Date(existing.updatedAt)
|
||||||
|
if (remoteNewer) {
|
||||||
|
map.set(m.id, { ...m, syncStatus: 'synced' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.values())
|
return Array.from(map.values()).filter(m => !m.deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
|
async function fetchRemote(config: { url: string; username: string; password: string }): Promise<Measurement[]> {
|
||||||
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
|
const url = `${config.url.replace(/\/+$/, '')}/${WEBDAV_PATH}`
|
||||||
const response = await fetch(url, {
|
const auth = encodeBase64(`${config.username}:${config.password}`)
|
||||||
headers: {
|
|
||||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
const controller = new AbortController()
|
||||||
return []
|
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Basic ${auth}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV fetch failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`WebDAV fetch failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
|
async function createDir(config: { url: string; username: string; password: string }): Promise<void> {
|
||||||
const base = config.url.replace(/\/+$/, '')
|
const base = config.url.replace(/\/+$/, '')
|
||||||
const dirUrl = `${base}/weegschaal`
|
const dirUrl = `${base}/weegschaal`
|
||||||
|
const auth = encodeBase64(`${config.username}:${config.password}`)
|
||||||
|
|
||||||
const response = await fetch(dirUrl, {
|
const controller = new AbortController()
|
||||||
method: 'MKCOL',
|
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
|
||||||
headers: {
|
|
||||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status !== 201 && response.status !== 405) {
|
try {
|
||||||
throw new Error(`WebDAV directory create failed: ${response.status}`)
|
const response = await fetch(dirUrl, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: { Authorization: `Basic ${auth}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 201 && response.status !== 405) {
|
||||||
|
throw new Error(`WebDAV directory create failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,20 +87,28 @@ async function uploadRemote(
|
||||||
const base = config.url.replace(/\/+$/, '')
|
const base = config.url.replace(/\/+$/, '')
|
||||||
const url = `${base}/${WEBDAV_PATH}`
|
const url = `${base}/${WEBDAV_PATH}`
|
||||||
const body = JSON.stringify(measurements)
|
const body = JSON.stringify(measurements)
|
||||||
|
const auth = encodeBase64(`${config.username}:${config.password}`)
|
||||||
|
|
||||||
await createDir(config)
|
await createDir(config)
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const controller = new AbortController()
|
||||||
method: 'PUT',
|
const timer = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS)
|
||||||
headers: {
|
|
||||||
Authorization: 'Basic ' + btoa(`${config.username}:${config.password}`),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`WebDAV upload failed: ${response.status}`)
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV upload failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +120,7 @@ export async function syncAll(): Promise<SyncResult> {
|
||||||
|
|
||||||
await initDatabase()
|
await initDatabase()
|
||||||
|
|
||||||
const local = await getAllMeasurements()
|
const local = await getAllMeasurementsIncludingDeleted()
|
||||||
const remote = await fetchRemote(config)
|
const remote = await fetchRemote(config)
|
||||||
|
|
||||||
const merged = mergeMeasurements(local, remote)
|
const merged = mergeMeasurements(local, remote)
|
||||||
|
|
@ -102,8 +132,8 @@ export async function syncAll(): Promise<SyncResult> {
|
||||||
await uploadRemote(config, merged)
|
await uploadRemote(config, merged)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
local: local.length,
|
local: local.filter(m => !m.deleted).length,
|
||||||
remote: remote.length,
|
remote: remote.filter(m => !m.deleted).length,
|
||||||
merged: merged.length,
|
merged: merged.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type Measurement = {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
syncStatus: SyncStatus
|
syncStatus: SyncStatus
|
||||||
|
deleted: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebDAVConfig = {
|
export type WebDAVConfig = {
|
||||||
|
|
|
||||||
17
src/utils/base64.ts
Normal file
17
src/utils/base64.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||||
|
|
||||||
|
export function encodeBase64(str: string): string {
|
||||||
|
const bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match, p1) => String.fromCharCode(parseInt(p1, 16)))
|
||||||
|
let result = ''
|
||||||
|
let i = 0
|
||||||
|
while (i < bytes.length) {
|
||||||
|
const a = bytes.charCodeAt(i++) || 0
|
||||||
|
const b = bytes.charCodeAt(i++) || 0
|
||||||
|
const c = bytes.charCodeAt(i++) || 0
|
||||||
|
result += CHARS.charAt(a >> 2)
|
||||||
|
result += CHARS.charAt(((a & 3) << 4) | (b >> 4))
|
||||||
|
result += CHARS.charAt(isNaN(b) ? CHARS.length - 1 : ((b & 15) << 2) | (c >> 6))
|
||||||
|
result += CHARS.charAt(isNaN(c) ? CHARS.length - 1 : c & 63)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ const mockMeasurement: Measurement = {
|
||||||
createdAt: '2024-01-01T08:00:00.000Z',
|
createdAt: '2024-01-01T08:00:00.000Z',
|
||||||
updatedAt: '2024-01-01T08:00:00.000Z',
|
updatedAt: '2024-01-01T08:00:00.000Z',
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
|
deleted: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('database', () => {
|
describe('database', () => {
|
||||||
|
|
@ -28,6 +29,8 @@ describe('database', () => {
|
||||||
let getAllMeasurements: any
|
let getAllMeasurements: any
|
||||||
let getLatestMeasurement: any
|
let getLatestMeasurement: any
|
||||||
let updateMeasurement: any
|
let updateMeasurement: any
|
||||||
|
let deleteMeasurement: any
|
||||||
|
let getAllMeasurementsIncludingDeleted: any
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.resetModules()
|
jest.resetModules()
|
||||||
|
|
@ -35,8 +38,8 @@ describe('database', () => {
|
||||||
openDatabaseAsync: jest.fn(),
|
openDatabaseAsync: jest.fn(),
|
||||||
}))
|
}))
|
||||||
db = {
|
db = {
|
||||||
execAsync: jest.fn(),
|
execAsync: jest.fn(() => Promise.resolve()),
|
||||||
runAsync: jest.fn(),
|
runAsync: jest.fn(() => Promise.resolve()),
|
||||||
getAllAsync: jest.fn(),
|
getAllAsync: jest.fn(),
|
||||||
}
|
}
|
||||||
const sqlite = require('expo-sqlite')
|
const sqlite = require('expo-sqlite')
|
||||||
|
|
@ -47,6 +50,8 @@ describe('database', () => {
|
||||||
getAllMeasurements = mod.getAllMeasurements
|
getAllMeasurements = mod.getAllMeasurements
|
||||||
getLatestMeasurement = mod.getLatestMeasurement
|
getLatestMeasurement = mod.getLatestMeasurement
|
||||||
updateMeasurement = mod.updateMeasurement
|
updateMeasurement = mod.updateMeasurement
|
||||||
|
deleteMeasurement = mod.deleteMeasurement
|
||||||
|
getAllMeasurementsIncludingDeleted = mod.getAllMeasurementsIncludingDeleted
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initDatabase creates the measurements table', async () => {
|
it('initDatabase creates the measurements table', async () => {
|
||||||
|
|
@ -91,4 +96,33 @@ describe('database', () => {
|
||||||
expect.arrayContaining(['synced', mockMeasurement.id])
|
expect.arrayContaining(['synced', mockMeasurement.id])
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deleteMeasurement marks measurement as deleted', async () => {
|
||||||
|
db.runAsync.mockResolvedValue(undefined)
|
||||||
|
await initDatabase()
|
||||||
|
await deleteMeasurement(mockMeasurement.id)
|
||||||
|
expect(db.runAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('UPDATE measurements SET deleted = 1'),
|
||||||
|
expect.arrayContaining([mockMeasurement.id])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAllMeasurements filters out deleted records', async () => {
|
||||||
|
await initDatabase()
|
||||||
|
await getAllMeasurements()
|
||||||
|
expect(db.getAllAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('deleted = 0')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAllMeasurementsIncludingDeleted includes deleted records', async () => {
|
||||||
|
await initDatabase()
|
||||||
|
await getAllMeasurementsIncludingDeleted()
|
||||||
|
expect(db.getAllAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SELECT * FROM measurements')
|
||||||
|
)
|
||||||
|
expect(db.getAllAsync).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('deleted = 0')
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const base: Measurement = {
|
||||||
createdAt: '2024-06-01T08:00:00.000Z',
|
createdAt: '2024-06-01T08:00:00.000Z',
|
||||||
updatedAt: '2024-06-01T08:00:00.000Z',
|
updatedAt: '2024-06-01T08:00:00.000Z',
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
|
deleted: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('mergeMeasurements', () => {
|
describe('mergeMeasurements', () => {
|
||||||
|
|
@ -54,4 +55,48 @@ describe('mergeMeasurements', () => {
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0].weight).toBe(77)
|
expect(result[0].weight).toBe(77)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters out locally deleted items even if present on remote', () => {
|
||||||
|
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-03T08:00:00.000Z' }
|
||||||
|
const result = mergeMeasurements([local], [remote])
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters out remotely deleted items when remote is newer', () => {
|
||||||
|
const local = { ...base, deleted: 0 as const, updatedAt: '2024-06-04T08:00:00.000Z' }
|
||||||
|
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
const result = mergeMeasurements([local], [remote])
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps local active item when local is newer than remote deletion', () => {
|
||||||
|
const local = { ...base, deleted: 0 as const, weight: 78, updatedAt: '2024-06-06T08:00:00.000Z' }
|
||||||
|
const remote = { ...base, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
const result = mergeMeasurements([local], [remote])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].weight).toBe(78)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps active item when local deleted is older than remote active', () => {
|
||||||
|
const local = { ...base, deleted: 1 as const, updatedAt: '2024-06-01T08:00:00.000Z' }
|
||||||
|
const remote = { ...base, deleted: 0 as const, updatedAt: '2024-06-05T08:00:00.000Z', syncStatus: 'synced' as const }
|
||||||
|
const result = mergeMeasurements([local], [remote])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].weight).toBe(75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges multiple active and deleted items correctly', () => {
|
||||||
|
const localActive = { ...base, id: 'a', weight: 70, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
const localDeleted = { ...base, id: 'b', weight: 80, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
const remoteActive = { ...base, id: 'c', weight: 90, updatedAt: '2024-06-03T08:00:00.000Z', syncStatus: 'synced' as const }
|
||||||
|
const remoteDeleted = { ...base, id: 'd', weight: 100, deleted: 1 as const, updatedAt: '2024-06-05T08:00:00.000Z' }
|
||||||
|
|
||||||
|
const result = mergeMeasurements(
|
||||||
|
[localActive, localDeleted],
|
||||||
|
[remoteActive, remoteDeleted]
|
||||||
|
)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map(m => m.id).sort()).toEqual(['a', 'c'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue