001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.backup.impl;
019
020import static org.junit.jupiter.api.Assertions.assertEquals;
021import static org.junit.jupiter.api.Assertions.assertThrows;
022import static org.junit.jupiter.api.Assertions.assertTrue;
023import static org.mockito.ArgumentMatchers.any;
024import static org.mockito.ArgumentMatchers.anySet;
025import static org.mockito.ArgumentMatchers.eq;
026import static org.mockito.Mockito.doNothing;
027import static org.mockito.Mockito.doReturn;
028import static org.mockito.Mockito.doThrow;
029import static org.mockito.Mockito.mock;
030import static org.mockito.Mockito.mockStatic;
031import static org.mockito.Mockito.never;
032import static org.mockito.Mockito.spy;
033import static org.mockito.Mockito.times;
034import static org.mockito.Mockito.verify;
035import static org.mockito.Mockito.when;
036
037import java.io.IOException;
038import java.util.ArrayList;
039import java.util.Arrays;
040import java.util.Collections;
041import java.util.HashMap;
042import java.util.HashSet;
043import java.util.List;
044import java.util.Map;
045import java.util.Set;
046import org.apache.hadoop.conf.Configuration;
047import org.apache.hadoop.fs.FileSystem;
048import org.apache.hadoop.fs.Path;
049import org.apache.hadoop.hbase.TableName;
050import org.apache.hadoop.hbase.backup.BackupInfo;
051import org.apache.hadoop.hbase.backup.BackupInfo.BackupState;
052import org.apache.hadoop.hbase.backup.BackupType;
053import org.apache.hadoop.hbase.backup.util.BackupUtils;
054import org.apache.hadoop.hbase.client.Connection;
055import org.apache.hadoop.hbase.testclassification.SmallTests;
056import org.junit.jupiter.api.BeforeEach;
057import org.junit.jupiter.api.Tag;
058import org.junit.jupiter.api.Test;
059import org.mockito.ArgumentCaptor;
060import org.mockito.MockedStatic;
061
062/**
063 * Unit tests for {@link BackupAdminImpl}.
064 * <p>
065 * This class improves test coverage by validating the behavior of key methods in BackupAdminImpl.
066 * Some methods are made package-private to enable testing.
067 */
068@Tag(SmallTests.TAG)
069public class TestBackupAdminImpl {
070  private BackupAdminImpl backupAdminImpl;
071  private BackupSystemTable mockTable;
072
073  @BeforeEach
074  public void setUp() {
075    backupAdminImpl = new BackupAdminImpl(null);
076    mockTable = mock(BackupSystemTable.class);
077  }
078
079  /**
080   * Scenario: - The initial incremental table set contains "table1" and "table2" - Only "table1"
081   * still exists in backup history Expectation: - The backup system should delete the existing set
082   * - Then re-add a filtered set that includes only "table1"
083   */
084  @Test
085  public void testFinalizeDelete_addsRetainedTablesBack() throws IOException {
086    String backupRoot = "backupRoot1";
087    List<String> backupRoots = Collections.singletonList(backupRoot);
088
089    Set<TableName> initialTableSet =
090      new HashSet<>(Arrays.asList(TableName.valueOf("ns:table1"), TableName.valueOf("ns:table2")));
091
092    Map<TableName, List<BackupInfo>> backupHistory = new HashMap<>();
093    backupHistory.put(TableName.valueOf("ns:table1"), List.of(new BackupInfo())); // Only table1
094                                                                                  // retained
095
096    when(mockTable.getIncrementalBackupTableSet(backupRoot))
097      .thenReturn(new HashSet<>(initialTableSet));
098    when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot))
099      .thenReturn(backupHistory);
100
101    backupAdminImpl.finalizeDelete(backupRoots, mockTable);
102
103    // Always remove existing backup metadata
104    verify(mockTable).deleteIncrementalBackupTableSet(backupRoot);
105
106    // Re-add only retained tables (should be just table1)
107    @SuppressWarnings("unchecked")
108    ArgumentCaptor<Set<TableName>> captor =
109      (ArgumentCaptor<Set<TableName>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Set.class);
110    verify(mockTable).addIncrementalBackupTableSet(captor.capture(), eq(backupRoot));
111
112    Set<TableName> retained = captor.getValue();
113    assertEquals(1, retained.size());
114    assertTrue(retained.contains(TableName.valueOf("ns:table1")));
115  }
116
117  /**
118   * Scenario: - The incremental table set has "tableX" - No backups exist for this table
119   * Expectation: - Backup metadata should be deleted - Nothing should be re-added since no tables
120   * are retained
121   */
122  @Test
123  public void testFinalizeDelete_retainedSetEmpty_doesNotAddBack() throws IOException {
124    String backupRoot = "backupRoot2";
125    List<String> backupRoots = List.of(backupRoot);
126
127    Set<TableName> initialTableSet = Set.of(TableName.valueOf("ns:tableX"));
128    Map<TableName, List<BackupInfo>> backupHistory = Map.of(); // No overlap
129
130    when(mockTable.getIncrementalBackupTableSet(backupRoot))
131      .thenReturn(new HashSet<>(initialTableSet));
132    when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot))
133      .thenReturn(backupHistory);
134
135    backupAdminImpl.finalizeDelete(backupRoots, mockTable);
136
137    // Delete should be called
138    verify(mockTable).deleteIncrementalBackupTableSet(backupRoot);
139    // No add back since retained set is empty
140    verify(mockTable, never()).addIncrementalBackupTableSet(any(), eq(backupRoot));
141  }
142
143  /**
144   * Scenario: - Two backup roots: - root1: one table with valid backup history → should be retained
145   * - root2: one table with no history → should not be retained Expectation: - root1 metadata
146   * deleted and re-added - root2 metadata only deleted
147   */
148  @Test
149  public void testFinalizeDelete_multipleRoots() throws IOException {
150    String root1 = "root1";
151    String root2 = "root2";
152    List<String> roots = List.of(root1, root2);
153
154    TableName t1 = TableName.valueOf("ns:table1");
155    TableName t2 = TableName.valueOf("ns:table2");
156
157    // root1 setup
158    when(mockTable.getIncrementalBackupTableSet(root1)).thenReturn(new HashSet<>(List.of(t1)));
159    when(mockTable.getBackupHistoryForTableSet(Set.of(t1), root1))
160      .thenReturn(Map.of(t1, List.of(new BackupInfo())));
161
162    // root2 setup
163    when(mockTable.getIncrementalBackupTableSet(root2)).thenReturn(new HashSet<>(List.of(t2)));
164    when(mockTable.getBackupHistoryForTableSet(Set.of(t2), root2)).thenReturn(Map.of()); // empty
165                                                                                         // history
166
167    backupAdminImpl.finalizeDelete(roots, mockTable);
168
169    // root1: should delete and re-add table
170    verify(mockTable).deleteIncrementalBackupTableSet(root1);
171    verify(mockTable).addIncrementalBackupTableSet(Set.of(t1), root1);
172
173    // root2: delete only
174    verify(mockTable).deleteIncrementalBackupTableSet(root2);
175    verify(mockTable, never()).addIncrementalBackupTableSet(anySet(), eq(root2));
176  }
177
178  /**
179   * Verifies that {@code cleanupBackupDir} correctly deletes the target backup directory for a
180   * given table and backup ID using the mocked FileSystem.
181   * <p>
182   * This test ensures: - The correct path is constructed using BackupUtils. - FileSystem#delete is
183   * invoked with that path.
184   */
185  @Test
186  public void testCleanupBackupDir_deletesTargetDirSuccessfully() throws Exception {
187    // Setup test input
188    String backupId = "backup_001";
189    String backupRootDir = "/backup/root";
190    TableName table = TableName.valueOf("test_table");
191
192    BackupInfo mockBackupInfo = mock(BackupInfo.class);
193    when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir);
194    when(mockBackupInfo.getBackupId()).thenReturn(backupId);
195
196    Configuration conf = new Configuration();
197
198    // Spy BackupAdminImpl to mock getFileSystem behavior
199    backupAdminImpl = spy(backupAdminImpl);
200
201    FileSystem mockFs = mock(FileSystem.class);
202    Path expectedPath = new Path(BackupUtils.getTableBackupDir(backupRootDir, backupId, table));
203
204    // Mock getFileSystem to return our mock FileSystem
205    doReturn(mockFs).when(backupAdminImpl).getFileSystem(any(Path.class), eq(conf));
206    when(mockFs.delete(expectedPath, true)).thenReturn(true);
207
208    // Call the method under test
209    backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf);
210
211    // Verify the FileSystem delete call with correct path
212    verify(mockFs).delete(expectedPath, true);
213  }
214
215  /**
216   * Verifies that {@code cleanupBackupDir} throws an {@link IOException} if the FileSystem
217   * retrieval fails.
218   * <p>
219   * This test simulates an exception while trying to obtain the FileSystem, and expects the method
220   * to propagate the exception.
221   */
222  @Test
223  public void testCleanupBackupDir_throwsIOException() throws Exception {
224    // Setup test input
225    String backupId = "backup_003";
226    String backupRootDir = "/backup/root";
227    TableName table = TableName.valueOf("test_table");
228
229    BackupInfo mockBackupInfo = mock(BackupInfo.class);
230    when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir);
231    when(mockBackupInfo.getBackupId()).thenReturn(backupId);
232
233    Configuration conf = new Configuration();
234
235    // Spy BackupAdminImpl to inject failure in getFileSystem
236    backupAdminImpl = spy(backupAdminImpl);
237    doThrow(new IOException("FS error")).when(backupAdminImpl).getFileSystem(any(Path.class),
238      eq(conf));
239
240    // Call method and expect IOException
241    assertThrows(IOException.class,
242      () -> backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf));
243  }
244
245  /**
246   * Tests that when a current incremental backup is found in the history, all later incremental
247   * backups for the same table are returned. This simulates rolling forward from the current backup
248   * timestamp, capturing newer incremental backups that depend on it.
249   */
250  @Test
251  public void testGetAffectedBackupSessions() throws IOException {
252    BackupInfo current = mock(BackupInfo.class);
253    TableName table = TableName.valueOf("test_table");
254
255    when(current.getStartTs()).thenReturn(2000L);
256    when(current.getBackupId()).thenReturn("backup_002");
257    when(current.getBackupRootDir()).thenReturn("/backup/root");
258
259    BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table);
260    BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table);
261    BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.INCREMENTAL, table); // current
262    BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table);
263    BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table);
264
265    when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0));
266
267    List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
268
269    assertEquals(2, result.size());
270    assertTrue(result.containsAll(List.of(b3, b4)));
271  }
272
273  /**
274   * Tests that if a full backup appears after the current backup, the affected list is reset and
275   * incremental backups following that full backup are not included. This ensures full backups act
276   * as a reset boundary.
277   */
278  @Test
279  public void testGetAffectedBackupSessions_resetsOnFullBackup() throws IOException {
280    BackupInfo current = mock(BackupInfo.class);
281    TableName table = TableName.valueOf("test_table");
282
283    when(current.getStartTs()).thenReturn(1000L);
284    when(current.getBackupId()).thenReturn("backup_001");
285    when(current.getBackupRootDir()).thenReturn("/backup/root");
286
287    BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table);
288    BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current
289    BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.FULL, table);
290    BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table);
291
292    when(mockTable.getBackupHistory(any())).thenReturn(List.of(b3, b2, b1, b0));
293
294    List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
295
296    assertTrue(result.isEmpty());
297  }
298
299  /**
300   * Tests that backups for other tables are ignored, even if they are incremental and fall after
301   * the current backup. Only backups affecting the specified table should be considered.
302   */
303  @Test
304  public void testGetAffectedBackupSessions_skipsNonMatchingTable() throws IOException {
305    BackupInfo current = mock(BackupInfo.class);
306    TableName table = TableName.valueOf("test_table");
307
308    when(current.getStartTs()).thenReturn(1000L);
309    when(current.getBackupId()).thenReturn("backup_001");
310    when(current.getBackupRootDir()).thenReturn("/backup/root");
311
312    BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table);
313    BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current
314    BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.INCREMENTAL, table);
315    BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL,
316      TableName.valueOf("other_table"));
317    BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table);
318
319    when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0));
320
321    List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
322
323    assertEquals(2, result.size());
324    assertTrue(result.containsAll(List.of(b2, b4)));
325  }
326
327  /**
328   * Tests that a full backup for a different table is ignored and does not reset the affected list.
329   * Only full backups for the same table act as reset boundaries.
330   */
331  @Test
332  public void testGetAffectedBackupSessions_ignoresFullBackupOfOtherTable() throws IOException {
333    BackupInfo current = mock(BackupInfo.class);
334    TableName table = TableName.valueOf("test_table");
335
336    when(current.getStartTs()).thenReturn(1000L);
337    when(current.getBackupId()).thenReturn("backup_001");
338    when(current.getBackupRootDir()).thenReturn("/backup/root");
339
340    BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table);
341    BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current
342    // Full backup for other table - should be ignored
343    BackupInfo b2 =
344      createBackupInfo("backup_002", 2000L, BackupType.FULL, TableName.valueOf("other_table"));
345    BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table);
346    BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table);
347
348    when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0));
349
350    List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
351
352    // Full backup of other table should not reset, so we expect both incremental backups after
353    // current
354    assertEquals(2, result.size());
355    assertTrue(result.containsAll(List.of(b3, b4)));
356  }
357
358  private BackupInfo createBackupInfo(String id, long ts, BackupType type, TableName... tables) {
359    BackupInfo info = mock(BackupInfo.class);
360    when(info.getBackupId()).thenReturn(id);
361    when(info.getStartTs()).thenReturn(ts);
362    when(info.getType()).thenReturn(type);
363    List<TableName> tableList = Arrays.asList(tables);
364    when(info.getTableNames()).thenReturn(tableList);
365    when(info.getTableListAsString()).thenReturn(tableList.toString());
366    return info;
367  }
368
369  /**
370   * Tests that when a table is removed from a backup image that still contains other tables, it
371   * updates the BackupInfo correctly and does not delete the entire backup metadata.
372   */
373  @Test
374  public void testRemoveTableFromBackupImage() throws IOException {
375    // Arrange
376    TableName tableToRemove = TableName.valueOf("ns", "t1");
377    TableName remainingTable = TableName.valueOf("ns", "t2");
378
379    BackupInfo info = new BackupInfo();
380    info.setBackupId("backup_001");
381    info.setTables(List.of(tableToRemove, remainingTable));
382    info.setBackupRootDir("/backup/root");
383
384    BackupSystemTable sysTable = mock(BackupSystemTable.class);
385    Configuration conf = new Configuration();
386
387    Connection mockConn = mock(Connection.class);
388    when(mockConn.getConfiguration()).thenReturn(conf);
389    backupAdminImpl = spy(new BackupAdminImpl(mockConn));
390
391    doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any());
392
393    try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) {
394      mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any()))
395        .thenAnswer(invocation -> null); // no-op for safety
396
397      // Act
398      backupAdminImpl.removeTableFromBackupImage(info, tableToRemove, sysTable);
399
400      // Assert
401      assertEquals(1, info.getTableNames().size());
402      assertTrue(info.getTableNames().contains(remainingTable));
403
404      verify(sysTable).updateBackupInfo(info);
405      verify(sysTable, never()).deleteBackupInfo(any());
406      verify(backupAdminImpl).cleanupBackupDir(eq(info), eq(tableToRemove), eq(conf));
407
408      mockedStatic.verifyNoInteractions(); // should not call static cleanup for partial table
409                                           // removal
410    }
411  }
412
413  /**
414   * Tests that when the last table in a backup image is removed, the backup metadata is deleted
415   * entirely and static cleanup is invoked.
416   */
417  @Test
418  public void testRemoveTableFromBackupImageDeletesWhenLastTableRemoved() throws IOException {
419    // Arrange
420    TableName onlyTable = TableName.valueOf("ns", "t1");
421
422    BackupInfo info = new BackupInfo();
423    info.setBackupId("backup_002");
424    info.setTables(new ArrayList<>(List.of(onlyTable)));
425    info.setBackupRootDir("/backup/root");
426
427    BackupSystemTable sysTable = mock(BackupSystemTable.class);
428    Configuration conf = new Configuration();
429
430    Connection mockConn = mock(Connection.class);
431    when(mockConn.getConfiguration()).thenReturn(conf);
432    backupAdminImpl = spy(new BackupAdminImpl(mockConn));
433
434    doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any());
435
436    try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) {
437      mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any()))
438        .thenAnswer(invocation -> null); // no-op for static void
439
440      // Act
441      backupAdminImpl.removeTableFromBackupImage(info, onlyTable, sysTable);
442
443      // Assert
444      verify(sysTable).deleteBackupInfo("backup_002");
445      verify(sysTable, never()).updateBackupInfo(any());
446
447      mockedStatic.verify(() -> BackupUtils.cleanupBackupData(info, conf));
448    }
449  }
450
451  /**
452   * Tests that when a backup ID is not found, the method logs a warning and returns 0.
453   */
454  @Test
455  public void testDeleteBackupWhenBackupInfoNotFound() throws IOException {
456    String backupId = "backup_missing";
457    BackupSystemTable sysTable = mock(BackupSystemTable.class);
458    when(sysTable.readBackupInfo(backupId)).thenReturn(null);
459
460    int result = backupAdminImpl.deleteBackup(backupId, sysTable);
461
462    assertEquals(0, result);
463    verify(sysTable, never()).deleteBackupInfo(any());
464  }
465
466  /**
467   * Tests deleting a FULL backup when it is the last session for all its tables. Ensures cleanup is
468   * called and metadata is deleted, but no other backups are affected.
469   */
470  @Test
471  public void testDeleteFullBackupWithLastSession() throws IOException {
472    TableName table = TableName.valueOf("ns", "t1");
473    String backupId = "backup_full_001";
474    BackupInfo info = new BackupInfo();
475    info.setBackupId(backupId);
476    info.setBackupRootDir("/backup/root");
477    info.setTables(List.of(table));
478    info.setStartTs(1000L);
479    info.setType(BackupType.FULL);
480
481    BackupSystemTable sysTable = mock(BackupSystemTable.class);
482    Configuration conf = new Configuration();
483    Connection mockConn = mock(Connection.class);
484    when(mockConn.getConfiguration()).thenReturn(conf);
485    backupAdminImpl = spy(new BackupAdminImpl(mockConn));
486
487    when(sysTable.readBackupInfo(backupId)).thenReturn(info);
488    when(backupAdminImpl.isLastBackupSession(sysTable, table, 1000L)).thenReturn(true);
489    when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of());
490
491    doNothing().when(sysTable).deleteBackupInfo(backupId);
492
493    try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) {
494      mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(info), eq(conf)))
495        .thenAnswer(inv -> null);
496
497      int result = backupAdminImpl.deleteBackup(backupId, sysTable);
498
499      assertEquals(1, result);
500      verify(sysTable).deleteBackupInfo(backupId);
501      mockedStatic.verify(() -> BackupUtils.cleanupBackupData(eq(info), eq(conf)));
502    }
503  }
504
505  /**
506   * Tests that deleteBackup will update other backups by removing the table when it's not the last
507   * session.
508   */
509  @Test
510  public void testDeleteBackupWithAffectedSessions() throws IOException {
511    TableName table = TableName.valueOf("ns", "t1");
512    String backupId = "backup_inc_001";
513    BackupInfo current = new BackupInfo();
514    current.setBackupId(backupId);
515    current.setBackupRootDir("/backup/root");
516    current.setTables(List.of(table));
517    current.setStartTs(2000L);
518    current.setType(BackupType.INCREMENTAL);
519
520    BackupInfo dependent = new BackupInfo();
521    dependent.setBackupId("backup_inc_002");
522    dependent.setBackupRootDir("/backup/root");
523    dependent.setTables(new ArrayList<>(List.of(table)));
524    dependent.setStartTs(3000L);
525    dependent.setType(BackupType.INCREMENTAL);
526
527    BackupSystemTable sysTable = mock(BackupSystemTable.class);
528    Configuration conf = new Configuration();
529    Connection mockConn = mock(Connection.class);
530    when(mockConn.getConfiguration()).thenReturn(conf);
531    backupAdminImpl = spy(new BackupAdminImpl(mockConn));
532
533    when(sysTable.readBackupInfo(backupId)).thenReturn(current);
534    when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of());
535    when(backupAdminImpl.isLastBackupSession(sysTable, table, 2000L)).thenReturn(false);
536    doReturn(List.of(dependent)).when(backupAdminImpl).getAffectedBackupSessions(current, table,
537      sysTable);
538
539    doNothing().when(backupAdminImpl).removeTableFromBackupImage(eq(dependent), eq(table),
540      eq(sysTable));
541
542    try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) {
543      mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(current), eq(conf)))
544        .thenAnswer(inv -> null);
545
546      int result = backupAdminImpl.deleteBackup(backupId, sysTable);
547
548      assertEquals(1, result);
549      verify(backupAdminImpl).removeTableFromBackupImage(dependent, table, sysTable);
550      verify(sysTable).deleteBackupInfo(backupId);
551      mockedStatic.verify(() -> BackupUtils.cleanupBackupData(current, conf));
552    }
553  }
554
555  /**
556   * Tests that deleteBackup will remove bulk-loaded files and handle exceptions gracefully.
557   */
558  @Test
559  public void testDeleteBackupWithBulkLoadedFiles() throws IOException {
560    // Set up test data
561    TableName table = TableName.valueOf("ns", "t1");
562    String backupId = "backup_with_bulkload";
563    Path dummyPath = new Path("/bulk/load/file1");
564    Map<byte[], String> bulkFiles = Map.of("k1".getBytes(), dummyPath.toString());
565
566    // BackupInfo mock
567    BackupInfo info = new BackupInfo();
568    info.setBackupId(backupId);
569    info.setBackupRootDir("/backup/root");
570    info.setTables(List.of(table));
571    info.setStartTs(1500L);
572    info.setType(BackupType.FULL);
573
574    // Create mock objects
575    Configuration conf = new Configuration();
576    Connection conn = mock(Connection.class);
577    when(conn.getConfiguration()).thenReturn(conf);
578
579    BackupSystemTable sysTable = mock(BackupSystemTable.class);
580    when(sysTable.readBackupInfo(backupId)).thenReturn(info);
581    when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(bulkFiles);
582
583    FileSystem fs = mock(FileSystem.class);
584
585    try (MockedStatic<FileSystem> fsStatic = mockStatic(FileSystem.class)) {
586      fsStatic.when(() -> FileSystem.get(conf)).thenReturn(fs);
587      when(fs.delete(dummyPath)).thenReturn(true); // Simulate successful delete
588
589      // Create spy on BackupAdminImpl
590      BackupAdminImpl backupAdmin = spy(new BackupAdminImpl(conn));
591      when(backupAdmin.isLastBackupSession(sysTable, table, 1500L)).thenReturn(true);
592
593      // No-ops for cleanup
594      doNothing().when(sysTable).deleteBackupInfo(backupId);
595      doNothing().when(sysTable).deleteBulkLoadedRows(any());
596
597      try (MockedStatic<BackupUtils> staticMock = mockStatic(BackupUtils.class)) {
598        staticMock.when(() -> BackupUtils.cleanupBackupData(info, conf))
599          .thenAnswer(invocation -> null);
600
601        // Execute method
602        int result = backupAdmin.deleteBackup(backupId, sysTable);
603
604        // Assertions
605        assertEquals(1, result);
606        verify(fs).delete(dummyPath);
607        verify(sysTable).deleteBulkLoadedRows(any());
608        verify(sysTable).deleteBackupInfo(backupId);
609        staticMock.verify(() -> BackupUtils.cleanupBackupData(info, conf), times(1));
610      }
611    }
612  }
613
614  /**
615   * Verifies that checkIfValidForMerge succeeds with valid INCREMENTAL, COMPLETE images from the
616   * same destination and no holes in the backup sequence.
617   */
618  @Test
619  public void testCheckIfValidForMerge_validCase() throws IOException {
620    String[] ids = { "b1", "b2" };
621    TableName t1 = TableName.valueOf("ns", "t1");
622    String dest = "/backup/root";
623
624    BackupInfo b1 =
625      createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, dest, t1);
626    BackupInfo b2 =
627      createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, dest, t1);
628
629    BackupSystemTable table = mock(BackupSystemTable.class);
630    when(table.readBackupInfo("b1")).thenReturn(b1);
631    when(table.readBackupInfo("b2")).thenReturn(b2);
632    when(table.getBackupHistory(any())).thenReturn(List.of(b1, b2));
633
634    new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table);
635  }
636
637  /**
638   * Verifies that checkIfValidForMerge fails if a FULL backup is included.
639   */
640  @Test
641  public void testCheckIfValidForMerge_failsWithFullBackup() throws IOException {
642    String[] ids = { "b1" };
643    TableName t1 = TableName.valueOf("ns", "t1");
644
645    BackupInfo b1 =
646      createBackupInfo("b1", BackupType.FULL, BackupState.COMPLETE, 1000L, "/dest", t1);
647
648    BackupSystemTable table = mock(BackupSystemTable.class);
649    when(table.readBackupInfo("b1")).thenReturn(b1);
650
651    assertThrows(IOException.class,
652      () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table));
653  }
654
655  /**
656   * Verifies that checkIfValidForMerge fails if one of the provided backup IDs is not found in the
657   * system table (i.e., null is returned).
658   */
659  @Test
660  public void testCheckIfValidForMerge_failsWhenBackupInfoNotFound() throws IOException {
661    String[] ids = { "b_missing" };
662
663    BackupSystemTable table = mock(BackupSystemTable.class);
664    when(table.readBackupInfo("b_missing")).thenReturn(null);
665
666    assertThrows(IOException.class,
667      () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table));
668  }
669
670  /**
671   * Verifies that checkIfValidForMerge fails when backups come from different destinations.
672   */
673  @Test
674  public void testCheckIfValidForMerge_failsWithDifferentDestinations() throws IOException {
675    String[] ids = { "b1", "b2" };
676    TableName t1 = TableName.valueOf("ns", "t1");
677
678    BackupInfo b1 =
679      createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, "/dest1", t1);
680    BackupInfo b2 =
681      createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, "/dest2", t1);
682
683    BackupSystemTable table = mock(BackupSystemTable.class);
684    when(table.readBackupInfo("b1")).thenReturn(b1);
685    when(table.readBackupInfo("b2")).thenReturn(b2);
686
687    assertThrows(IOException.class,
688      () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table));
689  }
690
691  /**
692   * Verifies that checkIfValidForMerge fails if any backup is not in COMPLETE state.
693   */
694  @Test
695  public void testCheckIfValidForMerge_failsWithNonCompleteState() throws IOException {
696    String[] ids = { "b1" };
697    TableName t1 = TableName.valueOf("ns", "t1");
698
699    BackupInfo b1 =
700      createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.RUNNING, 1000L, "/dest", t1);
701
702    BackupSystemTable table = mock(BackupSystemTable.class);
703    when(table.readBackupInfo("b1")).thenReturn(b1);
704
705    assertThrows(IOException.class,
706      () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table));
707  }
708
709  /**
710   * Verifies that checkIfValidForMerge fails when there is a "hole" in the backup sequence — i.e.,
711   * a required image from the full backup history is missing in the input list.
712   */
713  @Test
714  public void testCheckIfValidForMerge_failsWhenHoleInImages() throws IOException {
715    TableName t1 = TableName.valueOf("ns", "t1");
716    String dest = "/backup/root";
717
718    BackupInfo b1 =
719      createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, dest, t1);
720    BackupInfo b2 =
721      createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, dest, t1);
722    BackupInfo b3 =
723      createBackupInfo("b3", BackupType.INCREMENTAL, BackupState.COMPLETE, 3000L, dest, t1);
724
725    BackupSystemTable table = mock(BackupSystemTable.class);
726    when(table.readBackupInfo("b1")).thenReturn(b1);
727    when(table.readBackupInfo("b2")).thenReturn(b2);
728    when(table.readBackupInfo("b3")).thenReturn(b3);
729
730    when(table.getBackupHistory(any())).thenReturn(List.of(b1, b2, b3));
731
732    // Simulate a "hole" by omitting b2 from images
733    String[] idsWithHole = { "b1", "b3" };
734    assertThrows(IOException.class,
735      () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(idsWithHole, table));
736  }
737
738  private BackupInfo createBackupInfo(String id, BackupType type, BackupInfo.BackupState state,
739    long ts, String dest, TableName... tables) {
740    BackupInfo info = new BackupInfo();
741    info.setBackupId(id);
742    info.setType(type);
743    info.setState(state);
744    info.setStartTs(ts);
745    info.setBackupRootDir(dest);
746    info.setTables(List.of(tables));
747    return info;
748  }
749}