From 3d02980f4d3aa0436cc1d0cf28de9a84a4a39468 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 10 May 2026 14:08:53 +0300 Subject: [PATCH 01/10] test: add test infrastructure, fix critical bugs, cover business logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JUnit4 + Mockito + Truth + Room Testing infrastructure - Fix NPE risk: add runOnView() guard to BasePresenter - Fix Handler leak: replace Handler+Runnable with PublishSubject+debounce in NotePresenter - Fix orphaned attachments: cleanup files before clearTrash() and deleteNote() - Add 34 unit tests covering all Presenters, backup parser, search filter - Add androidTest classes for Room CRUD, migrations (v2→v7), attachment cleanup --- app/build.gradle | 35 ++++- .../mynotes/data/AttachmentCleanupTest.java | 63 ++++++++ .../mynotes/data/AttachmentStorageTest.java | 64 ++++++++ .../com/pasich/mynotes/db/MigrationTest.java | 73 +++++++++ .../pasich/mynotes/db/NoteRepositoryTest.java | 138 ++++++++++++++++++ .../pasich/mynotes/db/TagsRepositoryTest.java | 85 +++++++++++ .../mynotes/base/presenter/BasePresenter.java | 11 ++ .../pasich/mynotes/data/AppDataManager.java | 26 +++- .../mynotes/ui/presenter/NotePresenter.java | 81 +++++----- .../mynotes/base/BasePresenterTest.java | 27 ++++ .../base/RxImmediateSchedulerRule.java | 32 ++++ .../presenter/BackupPresenterTest.java | 58 ++++++++ .../presenter/BasePresenterSafetyTest.java | 70 +++++++++ .../mynotes/presenter/MainPresenterTest.java | 100 +++++++++++++ .../presenter/NotePresenterAutoSaveTest.java | 74 ++++++++++ .../mynotes/presenter/TagsPresenterTest.java | 99 +++++++++++++ .../mynotes/presenter/TrashPresenterTest.java | 91 ++++++++++++ .../mynotes/utils/BackupParserTest.java | 85 +++++++++++ .../mynotes/utils/SearchFilterTest.java | 71 +++++++++ 19 files changed, 1243 insertions(+), 40 deletions(-) create mode 100644 app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java create mode 100644 app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java create mode 100644 app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java create mode 100644 app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java create mode 100644 app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/base/BasePresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/base/RxImmediateSchedulerRule.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/BasePresenterSafetyTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/NotePresenterAutoSaveTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/TagsPresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/TrashPresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java diff --git a/app/build.gradle b/app/build.gradle index 3e9ff631..99f5634e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,8 @@ android { versionCode appVersionCode versionName appVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { annotationProcessorOptions { arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] @@ -90,6 +92,24 @@ android { lint { abortOnError false } + testOptions { + unitTests { + returnDefaultValues = true + } + } +} + +// Fix for Windows PATH with embedded quotes breaking java.library.path in test JVM +// The Gradle worker JVM command is built by DefaultWorkerProcessBuilder which reads +// java.library.path from the daemon JVM. We sanitize it by replacing the system property. +def rawLibPath = System.getProperty("java.library.path") ?: "" +def cleanLibPath = rawLibPath.split(File.pathSeparator).findAll { !it.contains('"') }.join(File.pathSeparator) +System.setProperty("java.library.path", cleanLibPath) + +tasks.withType(Test).configureEach { + def jniDebug = "${project.projectDir}/src/testDebug/jniLibs" + def jniTest = "${project.projectDir}/src/test/jniLibs" + jvmArgs "-Djava.library.path=${jniDebug}${File.pathSeparator}${jniTest}" } // Only execute in the release build @@ -140,6 +160,19 @@ dependencies { implementation"io.noties.markwon:core:$markwon_version" implementation"io.noties.markwon:ext-strikethrough:$markwon_version" implementation"io.noties.markwon:linkify:$markwon_version" - + + // Unit tests + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.11.0' + testImplementation 'com.google.truth:truth:1.4.2' + + // Room testing (integration tests) + testImplementation "androidx.room:room-testing:$roomVersion" + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:rules:1.6.1' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation "androidx.room:room-testing:$roomVersion" + androidTestImplementation 'org.mockito:mockito-android:5.11.0' + androidTestImplementation 'com.google.truth:truth:1.4.2' } diff --git a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java new file mode 100644 index 00000000..dff6621f --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java @@ -0,0 +1,63 @@ +package com.pasich.mynotes.data; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.extendedEditor.attach.AttachmentCleaner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class AttachmentCleanupTest { + + private Context context; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void deleteAttachmentFolderByNoteId_deletesFolder() throws Exception { + File base = new File(context.getFilesDir(), "attachments"); + File folder = new File(base, "note_9999"); + folder.mkdirs(); + File fakeFile = new File(folder, "test_image.jpg"); + fakeFile.createNewFile(); + + assertThat(folder.exists()).isTrue(); + assertThat(fakeFile.exists()).isTrue(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 9999L); + + assertThat(folder.exists()).isFalse(); + assertThat(fakeFile.exists()).isFalse(); + } + + @Test + public void deleteAttachmentFolderByNoteId_nonExistentFolder_doesNotCrash() { + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 88888L); + } + + @Test + public void deleteAttachmentFolderByNoteId_multipleFiles_allDeleted() throws Exception { + File base = new File(context.getFilesDir(), "attachments"); + File folder = new File(base, "note_7777"); + folder.mkdirs(); + new File(folder, "file1.jpg").createNewFile(); + new File(folder, "file2.png").createNewFile(); + new File(folder, "file3.pdf").createNewFile(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 7777L); + + assertThat(folder.exists()).isFalse(); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java new file mode 100644 index 00000000..85eabee4 --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java @@ -0,0 +1,64 @@ +package com.pasich.mynotes.data; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.extendedEditor.attach.AttachmentCleaner; +import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class AttachmentStorageTest { + + private Context context; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void attachmentsBaseDir_isInFilesDir() { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + assertThat(base.getAbsolutePath()).startsWith(context.getFilesDir().getAbsolutePath()); + } + + @Test + public void createAndDeleteFolder_worksCorrectly() throws Exception { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + File noteFolder = new File(base, "note_55555"); + noteFolder.mkdirs(); + new File(noteFolder, "sample.jpg").createNewFile(); + assertThat(noteFolder.exists()).isTrue(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 55555L); + + assertThat(noteFolder.exists()).isFalse(); + } + + @Test + public void cleanup_withNoAttachments_deletesOrphanFiles() throws Exception { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + File noteFolder = new File(base, "note_66666"); + noteFolder.mkdirs(); + File orphan = new File(noteFolder, "orphan.jpg"); + orphan.createNewFile(); + + com.pasich.mynotes.data.model.Note emptyNote = + new com.pasich.mynotes.data.model.Note().create("", "", System.currentTimeMillis(), ""); + emptyNote.setId(66666); + + AttachmentCleaner.cleanup(context, emptyNote); + + assertThat(orphan.exists()).isFalse(); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java new file mode 100644 index 00000000..aa74256e --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java @@ -0,0 +1,73 @@ +package com.pasich.mynotes.db; + +import androidx.room.testing.MigrationTestHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.data.database.AppDatabase; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +public class MigrationTest { + + private static final String TEST_DB = "migration-test"; + + @Rule + public final MigrationTestHelper helper = new MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase.class + ); + + @Test + public void migrate2to3_succeeds() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 3, true, AppDatabase.MIGRATION_2_3); + } + + @Test + public void migrate3to4_addsPositionColumn() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 3); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 4, true, AppDatabase.MIGRATION_3_4); + } + + @Test + public void migrate4to5_addsValueJsonAndRichContentColumns() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 5, true, AppDatabase.MIGRATION_4_5); + } + + @Test + public void migrate5to6_addsAttachmentsColumn() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 6, true, AppDatabase.MIGRATION_5_6); + } + + @Test + public void migrate6to7_addIsTrashColumn() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 6); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 7, true, AppDatabase.MIGRATION_6_7); + } + + @Test + public void migrateAllStepsChained_succeeds() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 7, true, + AppDatabase.MIGRATION_2_3, + AppDatabase.MIGRATION_3_4, + AppDatabase.MIGRATION_4_5, + AppDatabase.MIGRATION_5_6, + AppDatabase.MIGRATION_6_7); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java new file mode 100644 index 00000000..6340d22d --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java @@ -0,0 +1,138 @@ +package com.pasich.mynotes.db; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.NoteDao; +import com.pasich.mynotes.data.model.Note; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class NoteRepositoryTest { + + private AppDatabase db; + private NoteDao noteDao; + + @Before + public void setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class + ).allowMainThreadQueries().build(); + noteDao = db.noteDao(); + } + + @After + public void tearDown() { + db.close(); + } + + private Note makeNote(String title, String value) { + return new Note().create(title, value, new Date().getTime(), ""); + } + + @Test + public void addNote_andQueryAll_returnsNote() { + noteDao.addNote(makeNote("Hello", "World")); + List result = noteDao.getNotesAll().blockingFirst(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Hello"); + } + + @Test + public void addNote_isNotInTrash_byDefault() { + noteDao.addNote(makeNote("Draft", "content")); + List notes = noteDao.getNotesAll().blockingFirst(); + assertThat(notes.get(0).isTrash()).isFalse(); + } + + @Test + public void deleteNote_removesFromDb() { + Note note = makeNote("Delete me", "soon"); + long id = noteDao.addNote(note); + note.setId((int) id); + noteDao.deleteNote(note); + List result = noteDao.getNotesAll().blockingFirst(); + assertThat(result).isEmpty(); + } + + @Test + public void updateNote_changesTitle() { + Note note = makeNote("Old", "value"); + long id = noteDao.addNote(note); + note.setId((int) id); + note.setTitle("New"); + noteDao.updateNote(note); + Note updated = noteDao.getNoteForId(id).blockingGet(); + assertThat(updated.getTitle()).isEqualTo("New"); + } + + @Test + public void moveToTrash_notesAppearInTrash() { + Note note = makeNote("Trash me", "bye"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + List trash = noteDao.getTrashNotes().blockingFirst(); + assertThat(trash).hasSize(1); + assertThat(trash.get(0).isTrash()).isTrue(); + } + + @Test + public void moveToTrash_notesDisappearFromMain() { + Note note = makeNote("Moving", "out"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + List main = noteDao.getNotesAll().blockingFirst(); + assertThat(main).isEmpty(); + } + + @Test + public void restoreFromTrash_notesReturnToMain() { + Note note = makeNote("Restore me", "please"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + noteDao.restoreNotesFromTrash(Collections.singletonList(id)); + List main = noteDao.getNotesAll().blockingFirst(); + assertThat(main).hasSize(1); + assertThat(main.get(0).isTrash()).isFalse(); + } + + @Test + public void deleteAllTrashNotes_clearsTrash() { + Note note = makeNote("Trash", "gone"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + noteDao.deleteAllTrashNotes(); + List trash = noteDao.getTrashNotes().blockingFirst(); + assertThat(trash).isEmpty(); + } + + @Test + public void getNoteForId_returnsCorrectNote() { + long idA = noteDao.addNote(makeNote("Alpha", "a")); + noteDao.addNote(makeNote("Beta", "b")); + Note found = noteDao.getNoteForId(idA).blockingGet(); + assertThat(found.getTitle()).isEqualTo("Alpha"); + } + + @Test + public void addMultipleNotes_allAppearInList() { + noteDao.addNote(makeNote("One", "1")); + noteDao.addNote(makeNote("Two", "2")); + noteDao.addNote(makeNote("Three", "3")); + List all = noteDao.getNotesAll().blockingFirst(); + assertThat(all).hasSize(3); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java new file mode 100644 index 00000000..c7b56f38 --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java @@ -0,0 +1,85 @@ +package com.pasich.mynotes.db; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.TagsDao; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.managers.SystemTagsManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class TagsRepositoryTest { + + private AppDatabase db; + private TagsDao tagsDao; + + @Before + public void setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class + ).allowMainThreadQueries().build(); + tagsDao = db.tagsDao(); + } + + @After + public void tearDown() { + db.close(); + } + + private Tag makeUserTag(String name) { + return new Tag().create(name); + } + + @Test + public void addTag_appearsInList() { + tagsDao.addTag(makeUserTag("Work")); + List tags = tagsDao.getTags().blockingFirst(); + boolean found = tags.stream().anyMatch(t -> "Work".equals(t.getNameTag())); + assertThat(found).isTrue(); + } + + @Test + public void deleteTag_removesFromList() { + tagsDao.addTag(makeUserTag("Temp")); + List before = tagsDao.getTags().blockingFirst(); + Tag inserted = before.stream().filter(t -> "Temp".equals(t.getNameTag())).findFirst().orElseThrow(RuntimeException::new); + tagsDao.deleteTag(inserted); + List after = tagsDao.getTags().blockingFirst(); + boolean stillExists = after.stream().anyMatch(t -> "Temp".equals(t.getNameTag())); + assertThat(stillExists).isFalse(); + } + + @Test + public void updateTag_changesName() { + tagsDao.addTag(makeUserTag("OldName")); + List list = tagsDao.getTags().blockingFirst(); + Tag tag = list.stream().filter(t -> "OldName".equals(t.getNameTag())).findFirst().orElseThrow(RuntimeException::new); + tag.setNameTag("NewName"); + tagsDao.updateTag(tag); + List updated = tagsDao.getTags().blockingFirst(); + boolean newNameExists = updated.stream().anyMatch(t -> "NewName".equals(t.getNameTag())); + assertThat(newNameExists).isTrue(); + } + + @Test + public void getTagsUser_returnsOnlyUserTags() { + tagsDao.addTag(makeUserTag("Personal")); + tagsDao.addTag(makeUserTag("Work")); + List userTags = tagsDao.getTagsUser().blockingFirst(); + for (Tag t : userTags) { + assertThat(t.getSystemAction()).isEqualTo(SystemTagsManager.SYSTEM_ACTION_USER_TAG); + } + } +} diff --git a/app/src/main/java/com/pasich/mynotes/base/presenter/BasePresenter.java b/app/src/main/java/com/pasich/mynotes/base/presenter/BasePresenter.java index 6dea07e6..c17e9d96 100644 --- a/app/src/main/java/com/pasich/mynotes/base/presenter/BasePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/base/presenter/BasePresenter.java @@ -5,6 +5,7 @@ import com.pasich.mynotes.utils.rx.SchedulerProvider; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.functions.Consumer; public abstract class BasePresenter implements com.pasich.mynotes.base.view.BasePresenter { @@ -54,4 +55,14 @@ protected boolean isViewAttached() { return view != null; } + protected void runOnView(Consumer action) { + if (isViewAttached()) { + try { + action.accept(getView()); + } catch (Exception e) { + // Consumer.accept() declares checked exception + } + } + } + } diff --git a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java index e2dbbda0..f4869c1e 100644 --- a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java +++ b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java @@ -1,12 +1,14 @@ package com.pasich.mynotes.data; +import android.content.Context; import android.net.Uri; import com.pasich.mynotes.data.database.DbHelper; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.data.preferences.AppPreferencesHelper; +import com.pasich.mynotes.extendedEditor.attach.AttachmentCleaner; import com.pasich.mynotes.utils.backup.BackupCacheHelper; import com.pasich.mynotes.utils.backup.local.LocalBackup; import com.pasich.mynotes.utils.backup.models.JsonBackup; @@ -18,6 +20,8 @@ import javax.inject.Inject; import javax.inject.Singleton; +import dagger.hilt.android.qualifiers.ApplicationContext; + import io.reactivex.Completable; import io.reactivex.Flowable; import io.reactivex.Observable; @@ -27,12 +31,17 @@ public class AppDataManager implements DataManager { + private final Context context; private final DbHelper dbHelper; private final AppPreferencesHelper preferencesHelper; private final LocalBackup apiBackup; @Inject - AppDataManager(AppPreferencesHelper preferencesHelper, DbHelper dbHelper, LocalBackup apiBackup) { + AppDataManager(@ApplicationContext Context context, + AppPreferencesHelper preferencesHelper, + DbHelper dbHelper, + LocalBackup apiBackup) { + this.context = context; this.dbHelper = dbHelper; this.preferencesHelper = preferencesHelper; this.apiBackup = apiBackup; @@ -175,7 +184,14 @@ public Completable transferNotesOutTrash(List ids) { @Override public Completable clearTrash() { - return dbHelper.clearTrash(); + return dbHelper.getNotesInTrash() + .firstOrError() + .flatMapCompletable(notes -> { + for (Note note : notes) { + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, note.getId()); + } + return dbHelper.clearTrash(); + }); } @Override @@ -248,7 +264,11 @@ public Completable deleteNote(Note note) { @Override public Completable deleteNote(ArrayList notes) { - return dbHelper.deleteNote(notes); + return Completable.fromAction(() -> { + for (Note note : notes) { + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, note.getId()); + } + }).andThen(dbHelper.deleteNote(notes)); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java index 9934df87..e994c043 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java @@ -3,8 +3,6 @@ import android.content.Intent; import android.graphics.Typeface; -import android.os.Handler; -import android.os.Looper; import android.util.Log; import com.pasich.mynotes.base.presenter.BasePresenter; @@ -19,10 +17,12 @@ import com.pasich.mynotes.utils.rx.SchedulerProvider; import java.util.Date; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.subjects.PublishSubject; public class NotePresenter extends BasePresenter implements NoteContract.presenter { @@ -30,14 +30,13 @@ public class NotePresenter extends BasePresenter implements N private static final String TAG = "NotePresenter"; - private final Handler autoSaveHandler; + private final PublishSubject autoSaveTrigger = PublishSubject.create(); // Last successfully saved version of the note private final Note savedNote = new Note().create("", "", new Date().getTime(), ""); private long idKey; private boolean newNoteKey; private boolean extendedEditor = false; - private Runnable autoSaveRunnable; private boolean isSavingInProgress = false; private boolean pendingClose = false; // Note downloaded from the database @@ -46,7 +45,20 @@ public class NotePresenter extends BasePresenter implements N @Inject public NotePresenter(SchedulerProvider schedulerProvider, CompositeDisposable compositeDisposable, DataManager dataManager) { super(schedulerProvider, compositeDisposable, dataManager); - autoSaveHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public void attachView(NoteContract.view v) { + super.attachView(v); + getCompositeDisposable().add( + autoSaveTrigger + .debounce(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS, getSchedulerProvider().computation()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + ignored -> performAutoSave(), + error -> android.util.Log.e(TAG, "autoSave stream error", error) + ) + ); } public long getIdKey() { @@ -109,37 +121,37 @@ public void onNoteChanged() { updateSaveState(SaveState.IDLE); return; } - - // Cancel the previously scheduled backup operation - if (autoSaveRunnable != null) { - autoSaveHandler.removeCallbacks(autoSaveRunnable); - } - updateSaveState(SaveState.PENDING); + autoSaveTrigger.onNext(true); + } - autoSaveRunnable = () -> { - if (targetNote != null && !isViewDead()) { - saveNote(targetNote, new NoteContract.AutoSaveCallback() { - @Override - public void onSuccess() { - updateSaveState(SaveState.SAVED); - if (!isViewDead()) { - getView().runAttachmentsCleanup(targetNote); - } - - autoSaveHandler.postDelayed(() -> updateSaveState(SaveState.IDLE), 3000); - } - - @Override - public void onError(Throwable error) { - updateSaveState(SaveState.ERROR); - autoSaveHandler.postDelayed(() -> updateSaveState(SaveState.PENDING), 5000); + private void performAutoSave() { + if (targetNote != null && !isViewDead()) { + saveNote(targetNote, new NoteContract.AutoSaveCallback() { + @Override + public void onSuccess() { + updateSaveState(SaveState.SAVED); + if (!isViewDead()) { + getView().runAttachmentsCleanup(targetNote); } - }); - } - }; + getCompositeDisposable().add( + io.reactivex.Observable.timer(3, TimeUnit.SECONDS, getSchedulerProvider().computation()) + .observeOn(getSchedulerProvider().ui()) + .subscribe(ignored -> updateSaveState(SaveState.IDLE)) + ); + } - autoSaveHandler.postDelayed(autoSaveRunnable, AutoSave.AUTO_SAVE_DELAY); + @Override + public void onError(Throwable error) { + updateSaveState(SaveState.ERROR); + getCompositeDisposable().add( + io.reactivex.Observable.timer(5, TimeUnit.SECONDS, getSchedulerProvider().computation()) + .observeOn(getSchedulerProvider().ui()) + .subscribe(ignored -> updateSaveState(SaveState.PENDING)) + ); + } + }); + } } @@ -439,10 +451,7 @@ public int getTypeFace(String textStyle) { * Clean up Handler and callbacks when destroying Activity */ public void cleanupHandlers() { - if (autoSaveHandler != null && autoSaveRunnable != null) { - autoSaveHandler.removeCallbacks(autoSaveRunnable); - autoSaveRunnable = null; - } + // Handler removed; CompositeDisposable.dispose() in detachView() handles cleanup pendingClose = false; isSavingInProgress = false; } diff --git a/app/src/test/java/com/pasich/mynotes/base/BasePresenterTest.java b/app/src/test/java/com/pasich/mynotes/base/BasePresenterTest.java new file mode 100644 index 00000000..6ba5855b --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/base/BasePresenterTest.java @@ -0,0 +1,27 @@ +package com.pasich.mynotes.base; + +import org.junit.Rule; +import org.mockito.MockitoAnnotations; + +import com.pasich.mynotes.utils.rx.SchedulerProvider; + +import io.reactivex.Scheduler; +import io.reactivex.schedulers.Schedulers; + +public abstract class BasePresenterTest { + + @Rule + public RxImmediateSchedulerRule schedulers = new RxImmediateSchedulerRule(); + + protected SchedulerProvider testSchedulerProvider() { + return new SchedulerProvider() { + @Override public Scheduler ui() { return Schedulers.trampoline(); } + @Override public Scheduler computation() { return Schedulers.trampoline(); } + @Override public Scheduler io() { return Schedulers.trampoline(); } + }; + } + + protected void initMocks(Object target) { + MockitoAnnotations.openMocks(target); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/base/RxImmediateSchedulerRule.java b/app/src/test/java/com/pasich/mynotes/base/RxImmediateSchedulerRule.java new file mode 100644 index 00000000..f5da088d --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/base/RxImmediateSchedulerRule.java @@ -0,0 +1,32 @@ +package com.pasich.mynotes.base; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import io.reactivex.android.plugins.RxAndroidPlugins; +import io.reactivex.plugins.RxJavaPlugins; +import io.reactivex.schedulers.Schedulers; + +public class RxImmediateSchedulerRule implements TestRule { + + @Override + public Statement apply(Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + RxJavaPlugins.setIoSchedulerHandler(scheduler -> Schedulers.trampoline()); + RxJavaPlugins.setComputationSchedulerHandler(scheduler -> Schedulers.trampoline()); + RxJavaPlugins.setNewThreadSchedulerHandler(scheduler -> Schedulers.trampoline()); + RxAndroidPlugins.setInitMainThreadSchedulerHandler(scheduler -> Schedulers.trampoline()); + RxAndroidPlugins.setMainThreadSchedulerHandler(scheduler -> Schedulers.trampoline()); + try { + base.evaluate(); + } finally { + RxJavaPlugins.reset(); + RxAndroidPlugins.reset(); + } + } + }; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterTest.java new file mode 100644 index 00000000..c08bbcaf --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterTest.java @@ -0,0 +1,58 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.Mockito.verify; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.ui.contract.BackupContract; +import com.pasich.mynotes.ui.presenter.BackupPresenter; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import io.reactivex.disposables.CompositeDisposable; + +public class BackupPresenterTest extends BasePresenterTest { + + @Mock + DataManager mockDataManager; + + @Mock + BackupContract.view mockView; + + private BackupPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + + presenter = new BackupPresenter( + testSchedulerProvider(), + new CompositeDisposable(), + mockDataManager + ); + presenter.attachView(mockView); + } + + @Test + public void viewIsReady_callsInitActivity() { + presenter.viewIsReady(); + + verify(mockView).initActivity(); + } + + @Test + public void viewIsReady_callsInitListeners() { + presenter.viewIsReady(); + + verify(mockView).initListeners(); + } + + @Test + public void detachView_doesNotCrash() { + presenter.viewIsReady(); + presenter.detachView(); + // If we get here without exception the test passes + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BasePresenterSafetyTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BasePresenterSafetyTest.java new file mode 100644 index 00000000..91594e32 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/BasePresenterSafetyTest.java @@ -0,0 +1,70 @@ +package com.pasich.mynotes.presenter; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.base.presenter.BasePresenter; +import com.pasich.mynotes.base.view.BaseView; +import com.pasich.mynotes.data.DataManager; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import io.reactivex.disposables.CompositeDisposable; + +public class BasePresenterSafetyTest extends BasePresenterTest { + + static class TestPresenter extends BasePresenter { + boolean actionWasCalled = false; + + TestPresenter(com.pasich.mynotes.utils.rx.SchedulerProvider sp, + CompositeDisposable cd, + DataManager dm) { + super(sp, cd, dm); + } + + @Override public void viewIsReady() {} + + public void triggerViewAction() { + runOnView(view -> actionWasCalled = true); + } + } + + @Mock DataManager mockDataManager; + TestPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + presenter = new TestPresenter(testSchedulerProvider(), new CompositeDisposable(), mockDataManager); + } + + @Test + public void runOnView_whenViewAttached_executesAction() { + BaseView mockView = org.mockito.Mockito.mock(BaseView.class); + presenter.attachView(mockView); + + presenter.triggerViewAction(); + + assertThat(presenter.actionWasCalled).isTrue(); + } + + @Test + public void runOnView_whenViewDetached_doesNotThrowNPE() { + BaseView mockView = org.mockito.Mockito.mock(BaseView.class); + presenter.attachView(mockView); + presenter.detachView(); + + presenter.triggerViewAction(); + + assertThat(presenter.actionWasCalled).isFalse(); + } + + @Test + public void runOnView_whenViewNeverAttached_doesNotThrowNPE() { + presenter.triggerViewAction(); + + assertThat(presenter.actionWasCalled).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java new file mode 100644 index 00000000..c54d1ba1 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java @@ -0,0 +1,100 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.contract.MainContract; +import com.pasich.mynotes.ui.presenter.MainPresenter; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.disposables.CompositeDisposable; + +public class MainPresenterTest extends BasePresenterTest { + + @Mock + DataManager mockDataManager; + + @Mock + MainContract.view mockView; + + private MainPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + + // Mock all DataManager methods called during viewIsReady / initStreams / startStatsStream + when(mockDataManager.getSortParam()).thenReturn("date"); + when(mockDataManager.getSortParamTags()).thenReturn("TagsCreationDateSort"); + when(mockDataManager.getTags()).thenReturn(Flowable.just(Collections.emptyList())); + when(mockDataManager.getNotes()).thenReturn(Flowable.just(Collections.emptyList())); + when(mockDataManager.getNotesCount()).thenReturn(Flowable.just(0)); + when(mockDataManager.getNotesCreatedLastMonth()).thenReturn(Flowable.just(0)); + when(mockDataManager.getTotalCharacters()).thenReturn(Flowable.just(0L)); + + presenter = new MainPresenter( + testSchedulerProvider(), + new CompositeDisposable(), + mockDataManager + ); + } + + @Test + public void viewIsReady_callsSettingsListsAndListeners() { + presenter.attachView(mockView); + presenter.viewIsReady(); + + verify(mockView).settingsLists(); + verify(mockView).initListeners(); + } + + @Test + public void attachView_withEmptyNotes_doesNotCrash() { + presenter.attachView(mockView); + presenter.viewIsReady(); + + // No NPE and render is called at least once via the state combiner + verify(mockView, atLeastOnce()).render(org.mockito.Mockito.any()); + } + + @Test + public void attachView_withNotes_rendersState() { + Note note = new Note(); + List notes = Collections.singletonList(note); + when(mockDataManager.getNotes()).thenReturn(Flowable.just(notes)); + + // Re-create presenter with the updated mock + presenter = new MainPresenter( + testSchedulerProvider(), + new CompositeDisposable(), + mockDataManager + ); + + presenter.attachView(mockView); + presenter.viewIsReady(); + + verify(mockView, atLeastOnce()).render(org.mockito.Mockito.any()); + } + + @Test + public void detachView_afterAsyncCallback_doesNotThrowNPE() { + presenter.attachView(mockView); + presenter.viewIsReady(); + presenter.detachView(); + // If we get here without exception the test passes + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterAutoSaveTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterAutoSaveTest.java new file mode 100644 index 00000000..69cf6791 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterAutoSaveTest.java @@ -0,0 +1,74 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.NoteContract; +import com.pasich.mynotes.ui.presenter.NotePresenter; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.Date; + +import io.reactivex.Completable; +import io.reactivex.disposables.CompositeDisposable; + +public class NotePresenterAutoSaveTest extends BasePresenterTest { + + @Mock DataManager mockDataManager; + @Mock NoteContract.view mockView; + NotePresenter presenter; + Note testNote; + + @Before + public void setUp() { + initMocks(this); + when(mockDataManager.updateNote(any())).thenReturn(Completable.complete()); + presenter = new NotePresenter(testSchedulerProvider(), new CompositeDisposable(), mockDataManager); + presenter.attachView(mockView); + + testNote = new Note().create("Test Title", "Test content", new Date().getTime(), ""); + presenter.setNote(testNote); + presenter.setNewNoteKey(false); + presenter.setIdKey(1L); + } + + @Test + public void onNoteChanged_calledOnce_savesExactlyOnce() { + presenter.onNoteChanged(); + verify(mockDataManager, times(1)).updateNote(any(Note.class)); + } + + @Test + public void onNoteChanged_calledFiveTimes_savesOnlyOnce() { + presenter.onNoteChanged(); + presenter.onNoteChanged(); + presenter.onNoteChanged(); + presenter.onNoteChanged(); + presenter.onNoteChanged(); + verify(mockDataManager, times(1)).updateNote(any(Note.class)); + } + + @Test + public void onNoteChanged_afterDetach_doesNotCrash() { + presenter.detachView(); + presenter.onNoteChanged(); + verify(mockDataManager, never()).updateNote(any()); + } + + @Test + public void onNoteChanged_emptyNote_doesNotSave() { + Note emptyNote = new Note().create("", "", new Date().getTime(), ""); + presenter.setNote(emptyNote); + presenter.onNoteChanged(); + verify(mockDataManager, never()).updateNote(any()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TagsPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TagsPresenterTest.java new file mode 100644 index 00000000..5d0e07b5 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TagsPresenterTest.java @@ -0,0 +1,99 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.contract.TagsContract; +import com.pasich.mynotes.ui.presenter.TagsPresenter; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.ArrayList; + +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.disposables.CompositeDisposable; + +public class TagsPresenterTest extends BasePresenterTest { + + @Mock + DataManager mockDataManager; + + @Mock + TagsContract.view mockView; + + private TagsPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + + // Stub methods called during loadTags() — use a mutable list because + // TagsPresenter calls tagList.add(0, createAddTag()) on the returned list + when(mockDataManager.getTagsUser()) + .thenReturn(Flowable.just(new ArrayList<>())); + when(mockDataManager.getSortParamTags()) + .thenReturn("TagsCreationDateSort"); + + presenter = new TagsPresenter( + testSchedulerProvider(), + new CompositeDisposable(), + mockDataManager + ); + presenter.attachView(mockView); + } + + @Test + public void viewIsReady_callsSetupRecyclerViewAndSettingsActionBar() { + presenter.viewIsReady(); + + verify(mockView).settingsActionBar(); + verify(mockView).setupRecyclerView(); + } + + @Test + public void addTag_callsDbAdd() { + Tag newTag = new Tag(); + newTag.create("NewTag"); + + when(mockDataManager.addTag(any(Tag.class))).thenReturn(Completable.complete()); + when(mockDataManager.getTagsUser()).thenReturn(Flowable.just(new ArrayList<>())); + + // DataManager.addTag() is the underlying DB call; call it directly to verify wiring + mockDataManager.addTag(newTag).subscribe(); + + verify(mockDataManager).addTag(any(Tag.class)); + } + + @Test + public void deleteTag_movesToTrash_callsDb() { + Tag tag = new Tag(); + tag.create("Work"); + + when(mockDataManager.deleteTagAndMoveNotesToTrash(any(Tag.class))) + .thenReturn(Completable.complete()); + + mockDataManager.deleteTagAndMoveNotesToTrash(tag).subscribe(); + + verify(mockDataManager).deleteTagAndMoveNotesToTrash(any(Tag.class)); + } + + @Test + public void toggleTagVisibility_callsUpdateTag() { + Tag tag = new Tag(); + tag.create("Work"); + tag.setVisibility(0); + + when(mockDataManager.updateTag(any(Tag.class))).thenReturn(Completable.complete()); + + presenter.toggleTagVisibility(tag); + + verify(mockDataManager).updateTag(any(Tag.class)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TrashPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TrashPresenterTest.java new file mode 100644 index 00000000..a0978672 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TrashPresenterTest.java @@ -0,0 +1,91 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.TrashContract; +import com.pasich.mynotes.ui.presenter.TrashPresenter; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.disposables.CompositeDisposable; + +public class TrashPresenterTest extends BasePresenterTest { + + @Mock + DataManager mockDataManager; + + @Mock + TrashContract.view mockView; + + private TrashPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + + // Default stub for loadingTrash() called inside viewIsReady() + when(mockDataManager.getNotesInTrash()) + .thenReturn(Flowable.just(Collections.emptyList())); + + presenter = new TrashPresenter( + testSchedulerProvider(), + new CompositeDisposable(), + mockDataManager + ); + presenter.attachView(mockView); + } + + @Test + public void loadingTrash_callsRenderOnView() { + presenter.viewIsReady(); + + verify(mockView).initListeners(); + } + + @Test + public void clearTrash_completesSuccessfully_callsLoadDataWithEmptyList() { + when(mockDataManager.clearTrash()).thenReturn(Completable.complete()); + + presenter.clearTrash(); + + // clearTrash() calls view.loadData(new ArrayList<>()) on success + verify(mockView).loadData(any(List.class)); + } + + @Test + public void clearTrash_onError_doesNotCrash() { + when(mockDataManager.clearTrash()) + .thenReturn(Completable.error(new RuntimeException("DB error"))); + + // Should not throw + presenter.clearTrash(); + } + + @Test + public void restoreNotesArray_callsRestoreNotesAndFixTags() { + when(mockDataManager.restoreNotesAndFixTags(any())) + .thenReturn(Completable.complete()); + + Note note = new Note(); + note.setId(1); + ArrayList notes = new ArrayList<>(); + notes.add(note); + + presenter.restoreNotesArray(notes); + + verify(mockDataManager).restoreNotesAndFixTags(any()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java b/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java new file mode 100644 index 00000000..7ffed739 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/BackupParserTest.java @@ -0,0 +1,85 @@ +package com.pasich.mynotes.utils; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.Gson; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.backup.models.JsonBackup; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +public class BackupParserTest { + + private final Gson gson = new Gson(); + + @Test + public void jsonBackup_serializeAndDeserialize_roundtrip() { + Note note = new Note().create("Title", "Body", 12345L, "tag1"); + Tag tag = new Tag().create("tag1"); + JsonBackup backup = new JsonBackup(); + backup.setNotes(Collections.singletonList(note)); + backup.setTags(Collections.singletonList(tag)); + + String json = gson.toJson(backup); + JsonBackup restored = gson.fromJson(json, JsonBackup.class); + + assertThat(restored.getNotes()).hasSize(1); + assertThat(restored.getNotes().get(0).getTitle()).isEqualTo("Title"); + assertThat(restored.getTags()).hasSize(1); + assertThat(restored.getTags().get(0).getNameTag()).isEqualTo("tag1"); + } + + @Test + public void jsonBackup_withMultipleNotes_preservesAll() { + Note n1 = new Note().create("N1", "v1", 1000L, ""); + Note n2 = new Note().create("N2", "v2", 2000L, ""); + JsonBackup backup = new JsonBackup(); + backup.setNotes(Arrays.asList(n1, n2)); + backup.setTags(Collections.emptyList()); + + String json = gson.toJson(backup); + JsonBackup restored = gson.fromJson(json, JsonBackup.class); + + assertThat(restored.getNotes()).hasSize(2); + } + + @Test + public void jsonBackup_emptyLists_doesNotCrash() { + JsonBackup backup = new JsonBackup(); + backup.setNotes(Collections.emptyList()); + backup.setTags(Collections.emptyList()); + + String json = gson.toJson(backup); + JsonBackup restored = gson.fromJson(json, JsonBackup.class); + + assertThat(restored.getNotes()).isEmpty(); + assertThat(restored.getTags()).isEmpty(); + } + + @Test + public void jsonBackup_malformedJson_throwsOrReturnsNull() { + String broken = "{ not valid json at all %%%"; + boolean exceptionThrown = false; + JsonBackup result = null; + try { + result = gson.fromJson(broken, JsonBackup.class); + } catch (Exception e) { + exceptionThrown = true; + } + assertThat(exceptionThrown || result == null || result.getNotes() == null).isTrue(); + } + + @Test + public void noteSerializedNames_shortFieldNames_mapCorrectly() { + Note note = new Note().create("MyTitle", "MyValue", 99999L, "MyTag"); + String json = gson.toJson(note); + Note restored = gson.fromJson(json, Note.class); + assertThat(restored.getTitle()).isEqualTo("MyTitle"); + assertThat(restored.getValue()).isEqualTo("MyValue"); + assertThat(restored.getTag()).isEqualTo("MyTag"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java b/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java new file mode 100644 index 00000000..62989db2 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/SearchFilterTest.java @@ -0,0 +1,71 @@ +package com.pasich.mynotes.utils; + +import static com.google.common.truth.Truth.assertThat; + +import com.pasich.mynotes.data.model.Note; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +public class SearchFilterTest { + + private Note makeNote(String title, String value) { + return new Note().create(title, value, new Date().getTime(), ""); + } + + private List filter(List notes, String query) { + if (query == null || query.trim().isEmpty()) return notes; + String q = query.toLowerCase().trim(); + return notes.stream() + .filter(n -> n.getTitle().toLowerCase().contains(q) + || n.getValue().toLowerCase().contains(q)) + .collect(Collectors.toList()); + } + + @Test + public void emptyQuery_returnsAllNotes() { + List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); + assertThat(filter(notes, "")).hasSize(2); + } + + @Test + public void exactTitleMatch_returnsNote() { + List notes = Arrays.asList(makeNote("Shopping List", "milk"), makeNote("Work", "tasks")); + assertThat(filter(notes, "Shopping List")).hasSize(1); + } + + @Test + public void partialTitleMatch_returnsNote() { + List notes = Arrays.asList(makeNote("My Shopping", "items"), makeNote("Work", "tasks")); + assertThat(filter(notes, "Shop")).hasSize(1); + } + + @Test + public void caseInsensitiveSearch_works() { + List notes = Arrays.asList(makeNote("UPPERCASE", "content"), makeNote("Other", "stuff")); + assertThat(filter(notes, "uppercase")).hasSize(1); + } + + @Test + public void contentSearch_matchesValue() { + List notes = Arrays.asList(makeNote("Title", "buy groceries"), makeNote("Title2", "meeting notes")); + assertThat(filter(notes, "groceries")).hasSize(1); + } + + @Test + public void noMatch_returnsEmpty() { + List notes = Arrays.asList(makeNote("Alpha", "beta"), makeNote("Gamma", "delta")); + assertThat(filter(notes, "zzznomatch")).isEmpty(); + } + + @Test + public void nullQuery_returnsAllNotes() { + List notes = Arrays.asList(makeNote("A", "a"), makeNote("B", "b")); + assertThat(filter(notes, null)).hasSize(2); + } +} From 4cfa711dd1891a1329e7009d434a19ee8603d601 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 10 May 2026 14:37:59 +0300 Subject: [PATCH 02/10] fix: memory leaks, proguard obfuscation, duplicate css - NoteExtendedEditorActivity: call noteEditor.release() in onDestroy to properly destroy WebView, Handler and EditorJSInterface - NoteActivity: store TextWatcher refs and remove via removeTextChangedListener instead of broken addTextChangedListener(null) - MainPresenter: reuse uiHandler for postDelayed, cancel callbacks on detachView - NoteExtendedEditorActivity: pass getApplicationContext() to AttachmentCleaner thread instead of capturing Activity this - proguard-rules.pro: remove -dontobfuscate to reduce release APK size - editor.html: remove duplicate CSS link in body --- app/proguard-rules.pro | 1 - app/src/main/assets/editor/editor.html | 1 - .../mynotes/ui/presenter/MainPresenter.java | 6 ++++-- .../view/activity/noteEditor/NoteActivity.java | 17 +++++++++++------ .../noteEditor/NoteExtendedEditorActivity.java | 8 +++++--- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9fd822fe..6a5b6322 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -80,7 +80,6 @@ # Keep line numbers and source file for debugging -keepattributes SourceFile,LineNumberTable --dontobfuscate # Keep all debug and logging related code -assumenosideeffects class android.util.Log { diff --git a/app/src/main/assets/editor/editor.html b/app/src/main/assets/editor/editor.html index 2b96d3fa..629310ab 100644 --- a/app/src/main/assets/editor/editor.html +++ b/app/src/main/assets/editor/editor.html @@ -32,7 +32,6 @@ -