Add unit tests for data file rotation utility functions
- Implement tests for writing data files with rotation, ensuring backups are created only on changes. - Verify that old backups are rotated correctly and the maximum number of backups is maintained. - Test restoration of backups while preserving the current state as a backup. - Utilize Vitest for testing framework and manage temporary file storage during tests.
This commit is contained in:
@@ -37,6 +37,12 @@ data class SpielplanResponse(
|
|||||||
val seasons: List<SeasonDto> = emptyList(),
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||||
|
data class MannschaftenSeasonsResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
|
val currentSeason: String = "",
|
||||||
|
val defaultSeason: String = "",
|
||||||
|
)
|
||||||
data class SpielDto(
|
data class SpielDto(
|
||||||
@param:Json(name = "Termin") val termin: String = "",
|
@param:Json(name = "Termin") val termin: String = "",
|
||||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||||
@@ -584,6 +590,9 @@ interface ApiService {
|
|||||||
@GET("/api/mannschaften")
|
@GET("/api/mannschaften")
|
||||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("/api/mannschaften/seasons")
|
||||||
|
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||||
|
|
||||||
@GET("/api/config")
|
@GET("/api/config")
|
||||||
suspend fun config(): Response<ConfigResponse>
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -29,6 +31,12 @@ class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
|||||||
parseCsv(response.body()?.string().orEmpty())
|
parseCsv(response.body()?.string().orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||||
.filter(String::isNotBlank)
|
.filter(String::isNotBlank)
|
||||||
.drop(1)
|
.drop(1)
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -27,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -34,6 +38,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -43,6 +48,7 @@ import de.harheimertc.repositories.Mannschaft
|
|||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.theme.Accent700
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.theme.Primary100
|
import de.harheimertc.ui.theme.Primary100
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
@@ -64,8 +70,17 @@ fun MannschaftenScreen(
|
|||||||
BackLink(navController, showBackNavigation)
|
BackLink(navController, showBackNavigation)
|
||||||
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||||
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
if (state.seasons.isNotEmpty()) {
|
||||||
|
SeasonSelector(
|
||||||
|
seasons = state.seasons,
|
||||||
|
selectedSeason = state.selectedSeason,
|
||||||
|
onSeasonSelected = viewModel::selectSeason,
|
||||||
|
modifier = Modifier.padding(top = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
|
state.seasonsLoading -> item { Loading() }
|
||||||
state.loading -> item { Loading() }
|
state.loading -> item { Loading() }
|
||||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||||
@@ -88,6 +103,38 @@ fun MannschaftenScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonSelector(
|
||||||
|
seasons: List<de.harheimertc.data.SeasonDto>,
|
||||||
|
selectedSeason: String,
|
||||||
|
onSeasonSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||||
|
BoxWithConstraints {
|
||||||
|
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||||
|
Text("v")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
seasons.forEach { season ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(season.label.ifBlank { season.slug }) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onSeasonSelected(season.slug)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.LeagueTableRowDto
|
import de.harheimertc.data.LeagueTableRowDto
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.SpielplanRepository
|
import de.harheimertc.repositories.SpielplanRepository
|
||||||
@@ -17,6 +18,9 @@ data class MannschaftenUiState(
|
|||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val teams: List<Mannschaft> = emptyList(),
|
val teams: List<Mannschaft> = emptyList(),
|
||||||
|
val seasons: List<SeasonDto> = emptyList(),
|
||||||
|
val selectedSeason: String = "",
|
||||||
|
val seasonsLoading: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -27,17 +31,69 @@ class MannschaftenViewModel @Inject constructor(
|
|||||||
val state: StateFlow<MannschaftenUiState> = _state
|
val state: StateFlow<MannschaftenUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
loadSeasonsAndMannschaften()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = MannschaftenUiState(loading = true)
|
val season = _state.value.selectedSeason.ifBlank { null }
|
||||||
repository.fetchMannschaften()
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
|
repository.fetchMannschaften(season)
|
||||||
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
.onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
|
||||||
|
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectSeason(season: String) {
|
||||||
|
if (season == _state.value.selectedSeason) return
|
||||||
|
_state.value = _state.value.copy(selectedSeason = season)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSeasonsAndMannschaften() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(seasonsLoading = true, error = null)
|
||||||
|
repository.fetchSeasons()
|
||||||
|
.onSuccess { response ->
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
val seasons = response.seasons.ifEmpty { listOf(SeasonDto(slug = response.currentSeason.ifBlank { currentSeason }, label = formatSeasonLabel(response.currentSeason.ifBlank { currentSeason }))) }
|
||||||
|
val selectedSeason = when {
|
||||||
|
response.defaultSeason.isNotBlank() -> response.defaultSeason
|
||||||
|
seasons.any { it.slug == currentSeason } -> currentSeason
|
||||||
|
seasons.isNotEmpty() -> seasons.first().slug
|
||||||
|
else -> currentSeason
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = seasons,
|
||||||
|
selectedSeason = selectedSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
val currentSeason = getCurrentSeasonSlug()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
seasonsLoading = false,
|
||||||
|
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
|
||||||
|
selectedSeason = currentSeason,
|
||||||
|
)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentSeasonSlug(): String {
|
||||||
|
val now = java.util.Calendar.getInstance()
|
||||||
|
val year = now.get(java.util.Calendar.YEAR)
|
||||||
|
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
|
||||||
|
val endYear = startYear + 1
|
||||||
|
return "%02d--%02d".format(startYear % 100, endYear % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSeasonLabel(seasonSlug: String): String {
|
||||||
|
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
|
||||||
|
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MannschaftDetailUiState(
|
data class MannschaftDetailUiState(
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
|||||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||||
|
|
||||||
# Android app versioning for Play Store uploads
|
# Android app versioning for Play Store uploads
|
||||||
ANDROID_VERSION_CODE=19
|
ANDROID_VERSION_CODE=20
|
||||||
ANDROID_VERSION_NAME=0.9.14
|
ANDROID_VERSION_NAME=0.9.15
|
||||||
|
|
||||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||||
RELEASE_MINIFY_ENABLED=false
|
RELEASE_MINIFY_ENABLED=false
|
||||||
|
|||||||
@@ -16,9 +16,12 @@
|
|||||||
"start": "nuxt start --port 3100",
|
"start": "nuxt start --port 3100",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
|
||||||
"check-security": "node scripts/verify-no-public-writes.js",
|
"check-security": "node scripts/verify-no-public-writes.js",
|
||||||
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
||||||
"sync-public-data": "node scripts/sync-public-data.js",
|
"sync-public-data": "node scripts/sync-public-data.js",
|
||||||
|
"data-backups:list": "node scripts/data-backup-restore.js list",
|
||||||
|
"data-backups:restore": "node scripts/data-backup-restore.js restore",
|
||||||
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
|
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
|
||||||
"import-spielplan": "node scripts/import-spielplan.js",
|
"import-spielplan": "node scripts/import-spielplan.js",
|
||||||
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||||
|
|||||||
163
scripts/data-backup-restore.js
Normal file
163
scripts/data-backup-restore.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
getBackupDirectoryForDataFile,
|
||||||
|
listDataFileBackups,
|
||||||
|
restoreDataFileBackup
|
||||||
|
} from '../server/utils/data-file-rotation.js'
|
||||||
|
|
||||||
|
const FILES = {
|
||||||
|
'users.json': getDataPath('users.json'),
|
||||||
|
'sessions.json': getDataPath('sessions.json'),
|
||||||
|
'members.json': getDataPath('members.json'),
|
||||||
|
'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'),
|
||||||
|
'news.json': getDataPath('news.json'),
|
||||||
|
'termine.csv': getDataPath('termine.csv'),
|
||||||
|
'contact-requests.json': getDataPath('contact-requests.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataPath(filename) {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.join(cwd, '../server/data', filename)
|
||||||
|
}
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArg(name) {
|
||||||
|
const index = process.argv.findIndex((arg) => arg === name)
|
||||||
|
if (index === -1) return null
|
||||||
|
const next = process.argv[index + 1]
|
||||||
|
if (!next || next.startsWith('--')) return null
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(name) {
|
||||||
|
return process.argv.includes(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log('Verwendung:')
|
||||||
|
console.log(' node scripts/data-backup-restore.js list [--file users.json]')
|
||||||
|
console.log(' node scripts/data-backup-restore.js restore --file users.json --latest')
|
||||||
|
console.log(' node scripts/data-backup-restore.js restore --file users.json --backup <backup-datei.bak>')
|
||||||
|
console.log('')
|
||||||
|
console.log('Optionen:')
|
||||||
|
console.log(' --file Eine der bekannten Daten-Dateien')
|
||||||
|
console.log(' --latest Stellt das neueste Backup wieder her')
|
||||||
|
console.log(' --backup Konkreter Backup-Dateiname (*.bak)')
|
||||||
|
console.log('')
|
||||||
|
console.log('Bekannte Dateien:')
|
||||||
|
Object.keys(FILES).forEach((name) => console.log(` - ${name}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCommand() {
|
||||||
|
const requestedFile = parseArg('--file')
|
||||||
|
const names = requestedFile ? [requestedFile] : Object.keys(FILES)
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const dataPath = FILES[name]
|
||||||
|
if (!dataPath) {
|
||||||
|
console.error(`Unbekannte Datei: ${name}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataPath)
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(dataPath)
|
||||||
|
|
||||||
|
console.log(`\n${name}`)
|
||||||
|
console.log(` Datenpfad: ${dataPath}`)
|
||||||
|
console.log(` Backup-Ordner: ${backupDir}`)
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
console.log(' Backups: keine')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Backups (${backups.length}, neuestes zuerst):`)
|
||||||
|
backups.slice(0, 15).forEach((backup) => {
|
||||||
|
console.log(` - ${backup}`)
|
||||||
|
})
|
||||||
|
if (backups.length > 15) {
|
||||||
|
console.log(` ... (${backups.length - 15} weitere)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCommand() {
|
||||||
|
const fileName = parseArg('--file')
|
||||||
|
if (!fileName) {
|
||||||
|
console.error('Fehlend: --file <datei>')
|
||||||
|
printUsage()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPath = FILES[fileName]
|
||||||
|
if (!dataPath) {
|
||||||
|
console.error(`Unbekannte Datei: ${fileName}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataPath)
|
||||||
|
if (backups.length === 0) {
|
||||||
|
console.error(`Keine Backups gefunden für ${fileName}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupName = parseArg('--backup')
|
||||||
|
const latest = hasFlag('--latest')
|
||||||
|
|
||||||
|
let targetBackup = backupName
|
||||||
|
if (!targetBackup && latest) {
|
||||||
|
targetBackup = backups[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetBackup) {
|
||||||
|
console.error('Bitte --latest oder --backup <name> angeben')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backups.includes(targetBackup)) {
|
||||||
|
console.error(`Backup nicht gefunden: ${targetBackup}`)
|
||||||
|
console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file <datei>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await restoreDataFileBackup(dataPath, targetBackup)
|
||||||
|
|
||||||
|
console.log(`Wiederherstellung abgeschlossen: ${fileName}`)
|
||||||
|
console.log(` Eingespieltes Backup: ${targetBackup}`)
|
||||||
|
if (result.backupPath) {
|
||||||
|
console.log(` Backup des vorherigen Zustands: ${result.backupPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const command = process.argv[2]
|
||||||
|
|
||||||
|
if (!command || command === '--help' || command === '-h') {
|
||||||
|
printUsage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'list') {
|
||||||
|
await listCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'restore') {
|
||||||
|
await restoreCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Unbekannter Befehl: ${command}`)
|
||||||
|
printUsage()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Fehler im Backup/Restore-Skript:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import crypto from 'crypto'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Export migrateUserRoles für Verwendung in anderen Modulen
|
// Export migrateUserRoles für Verwendung in anderen Modulen
|
||||||
export function migrateUserRoles(user) {
|
export function migrateUserRoles(user) {
|
||||||
@@ -196,7 +197,7 @@ export async function writeUsers(users) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(users, encryptionKey)
|
const encryptedData = encryptObject(users, encryptionKey)
|
||||||
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
|
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
|
||||||
@@ -262,7 +263,7 @@ export async function writeSessions(sessions) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(sessions, encryptionKey)
|
const encryptedData = encryptObject(sessions, encryptionKey)
|
||||||
await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Sessions:', error)
|
console.error('Fehler beim Schreiben der Sessions:', error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant, never user input
|
// filename is always a hardcoded constant, never user input
|
||||||
@@ -29,7 +30,7 @@ export async function readContactRequests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function writeContactRequests(items) {
|
export async function writeContactRequests(items) {
|
||||||
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
|
await writeDataFileWithRotation(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), { encoding: 'utf-8' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContactRequest(data) {
|
export async function createContactRequest(data) {
|
||||||
|
|||||||
125
server/utils/data-file-rotation.js
Normal file
125
server/utils/data-file-rotation.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||||
|
|
||||||
|
function getProjectRoot() {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) {
|
||||||
|
return path.resolve(cwd, '..')
|
||||||
|
}
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupRoot() {
|
||||||
|
const configured = process.env.DATA_FILE_BACKUP_DIR
|
||||||
|
if (!configured) {
|
||||||
|
return path.join(getProjectRoot(), 'backups', 'data-rotation')
|
||||||
|
}
|
||||||
|
if (path.isAbsolute(configured)) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return path.join(getProjectRoot(), configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileKey(filePath) {
|
||||||
|
const projectRoot = getProjectRoot()
|
||||||
|
const relative = path.relative(projectRoot, filePath)
|
||||||
|
const normalized = relative.split(path.sep).join('__')
|
||||||
|
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupName(date = new Date()) {
|
||||||
|
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||||
|
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirectory(dirPath) {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateOldBackups(backupDir, maxBackups) {
|
||||||
|
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||||
|
const backups = entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const overflowCount = Math.max(0, backups.length - maxBackups)
|
||||||
|
if (overflowCount === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDelete = backups.slice(0, overflowCount)
|
||||||
|
await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {})))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupDirectoryForDataFile(filePath) {
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDataFileBackups(filePath) {
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(filePath)
|
||||||
|
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDataFileWithRotation(filePath, content, {
|
||||||
|
encoding = 'utf-8',
|
||||||
|
maxBackups = DEFAULT_MAX_BACKUPS
|
||||||
|
} = {}) {
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
await ensureDirectory(path.dirname(resolvedPath))
|
||||||
|
|
||||||
|
let existingContent = null
|
||||||
|
try {
|
||||||
|
existingContent = await fs.readFile(resolvedPath, encoding)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingContent !== null && existingContent === content) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
backupPath: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupPath = null
|
||||||
|
if (existingContent !== null) {
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||||
|
await ensureDirectory(backupDir)
|
||||||
|
backupPath = path.join(backupDir, buildBackupName())
|
||||||
|
await fs.copyFile(resolvedPath, backupPath)
|
||||||
|
await rotateOldBackups(backupDir, maxBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
await fs.writeFile(tmpPath, content, encoding)
|
||||||
|
await fs.rename(tmpPath, resolvedPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
backupPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
|
||||||
|
const resolvedPath = path.resolve(filePath)
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||||
|
const sourceBackupPath = path.join(backupDir, backupName)
|
||||||
|
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
|
||||||
|
|
||||||
|
return writeDataFileWithRotation(resolvedPath, backupContent, options)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -192,7 +193,7 @@ export async function writeMembers(members) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(members, encryptionKey)
|
const encryptedData = encryptObject(members, encryptionKey)
|
||||||
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
|
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -38,7 +39,7 @@ export async function readNews() {
|
|||||||
// Write news to file
|
// Write news to file
|
||||||
export async function writeNews(news) {
|
export async function writeNews(news) {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(NEWS_FILE, JSON.stringify(news, null, 2), 'utf-8')
|
await writeDataFileWithRotation(NEWS_FILE, JSON.stringify(news, null, 2), { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der News:', error)
|
console.error('Fehler beim Schreiben der News:', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readMembers } from './members.js'
|
|||||||
import { readUsers } from './auth.js'
|
import { readUsers } from './auth.js'
|
||||||
import { encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
|
||||||
@@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) {
|
|||||||
try {
|
try {
|
||||||
const encryptionKey = getEncryptionKey()
|
const encryptionKey = getEncryptionKey()
|
||||||
const encryptedData = encryptObject(subscribers, encryptionKey)
|
const encryptedData = encryptObject(subscribers, encryptionKey)
|
||||||
await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8')
|
await writeDataFileWithRotation(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
|
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Use internal server/data directory for Termine CSV to avoid writing to public/
|
// Use internal server/data directory for Termine CSV to avoid writing to public/
|
||||||
const getDataPath = (filename) => {
|
const getDataPath = (filename) => {
|
||||||
@@ -89,7 +90,7 @@ export async function writeTermine(termine) {
|
|||||||
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
|
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(TERMINE_FILE, csv, 'utf-8')
|
await writeDataFileWithRotation(TERMINE_FILE, csv, { encoding: 'utf-8' })
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Schreiben der Termine:', error)
|
console.error('Fehler beim Schreiben der Termine:', error)
|
||||||
|
|||||||
BIN
temp/device-43477-app-after-start.png
Normal file
BIN
temp/device-43477-app-after-start.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
BIN
temp/device-43477-mannschaften-home.png
Normal file
BIN
temp/device-43477-mannschaften-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
132
tests/data-file-rotation.spec.ts
Normal file
132
tests/data-file-rotation.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import os from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import {
|
||||||
|
getBackupDirectoryForDataFile,
|
||||||
|
listDataFileBackups,
|
||||||
|
restoreDataFileBackup,
|
||||||
|
writeDataFileWithRotation
|
||||||
|
} from '../server/utils/data-file-rotation.js'
|
||||||
|
|
||||||
|
describe('Data file rotation utility', () => {
|
||||||
|
let tempRoot = ''
|
||||||
|
let backupRoot = ''
|
||||||
|
let previousBackupDir = ''
|
||||||
|
let dataFile = ''
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harheimertc-data-rotation-'))
|
||||||
|
backupRoot = path.join(tempRoot, 'backup-store')
|
||||||
|
dataFile = path.join(tempRoot, 'server', 'data', 'users.json')
|
||||||
|
|
||||||
|
previousBackupDir = process.env.DATA_FILE_BACKUP_DIR || ''
|
||||||
|
process.env.DATA_FILE_BACKUP_DIR = backupRoot
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (previousBackupDir) {
|
||||||
|
process.env.DATA_FILE_BACKUP_DIR = previousBackupDir
|
||||||
|
} else {
|
||||||
|
delete process.env.DATA_FILE_BACKUP_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempRoot) {
|
||||||
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes initial file without creating backup', async () => {
|
||||||
|
const result = await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 })
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true)
|
||||||
|
expect(result.backupPath).toBe(null)
|
||||||
|
|
||||||
|
const content = await fs.readFile(dataFile, 'utf-8')
|
||||||
|
expect(content).toBe('v1')
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataFile)
|
||||||
|
expect(backups).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates backup from previous content on change', async () => {
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 })
|
||||||
|
const result = await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 5 })
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true)
|
||||||
|
expect(result.backupPath).toBeTruthy()
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataFile)
|
||||||
|
expect(backups.length).toBe(1)
|
||||||
|
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(dataFile)
|
||||||
|
const backupContent = await fs.readFile(path.join(backupDir, backups[0]), 'utf-8')
|
||||||
|
expect(backupContent).toBe('v1')
|
||||||
|
|
||||||
|
const currentContent = await fs.readFile(dataFile, 'utf-8')
|
||||||
|
expect(currentContent).toBe('v2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create backup when content is unchanged', async () => {
|
||||||
|
await writeDataFileWithRotation(dataFile, 'stable', { maxBackups: 5 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 })
|
||||||
|
|
||||||
|
const before = await listDataFileBackups(dataFile)
|
||||||
|
const result = await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 })
|
||||||
|
const after = await listDataFileBackups(dataFile)
|
||||||
|
|
||||||
|
expect(result.changed).toBe(false)
|
||||||
|
expect(result.backupPath).toBe(null)
|
||||||
|
expect(after).toEqual(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rotates old backups and keeps configured maximum', async () => {
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 2 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 2 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 2 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v4', { maxBackups: 2 })
|
||||||
|
|
||||||
|
const backups = await listDataFileBackups(dataFile)
|
||||||
|
expect(backups.length).toBe(2)
|
||||||
|
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(dataFile)
|
||||||
|
const backupContents = await Promise.all(
|
||||||
|
backups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8'))
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3']))
|
||||||
|
expect(backupContents).not.toContain('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores selected backup and keeps pre-restore state as backup', async () => {
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 10 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 10 })
|
||||||
|
await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 10 })
|
||||||
|
|
||||||
|
const beforeRestoreBackups = await listDataFileBackups(dataFile)
|
||||||
|
const backupDir = getBackupDirectoryForDataFile(dataFile)
|
||||||
|
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
beforeRestoreBackups.map(async (name) => ({
|
||||||
|
name,
|
||||||
|
content: await fs.readFile(path.join(backupDir, name), 'utf-8')
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const v1Entry = resolved.find((entry) => entry.content === 'v1')
|
||||||
|
expect(v1Entry).toBeTruthy()
|
||||||
|
|
||||||
|
const restoreResult = await restoreDataFileBackup(dataFile, v1Entry!.name, { maxBackups: 10 })
|
||||||
|
expect(restoreResult.changed).toBe(true)
|
||||||
|
expect(restoreResult.backupPath).toBeTruthy()
|
||||||
|
|
||||||
|
const restoredContent = await fs.readFile(dataFile, 'utf-8')
|
||||||
|
expect(restoredContent).toBe('v1')
|
||||||
|
|
||||||
|
const afterRestoreBackups = await listDataFileBackups(dataFile)
|
||||||
|
const afterContents = await Promise.all(
|
||||||
|
afterRestoreBackups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8'))
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(afterContents).toContain('v3')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user