File: test_plugin_usercallback.py

package info (click to toggle)
backintime 1.5.5-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 9,632 kB
  • sloc: python: 23,454; sh: 859; makefile: 172; xml: 62
file content (315 lines) | stat: -rw-r--r-- 10,990 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# SPDX-FileCopyrightText: © 2024 Christian Buhtz <c.buhtz@posteo.jp>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# This file is part of the program "Back In Time" which is released under GNU
# General Public License v2 (GPLv2). See LICENSES directory or go to
# <https://spdx.org/licenses/GPL-2.0-or-later.html>.
import sys
import inspect
import io
import unittest
import unittest.mock as mock
import tempfile
import stat
from contextlib import redirect_stdout, redirect_stderr
from ast import literal_eval
from pathlib import Path

# This workaround will become obsolet when migrating to src-layout
sys.path.append(str(Path(__file__).parent))
sys.path.append(str(Path(__file__).parent / 'plugins'))

import pluginmanager
from config import Config
from snapshots import Snapshots
from usercallbackplugin import UserCallbackPlugin


class UserCallback(unittest.TestCase):
    """Simple test related to to UserCallbackPlugin class.

    Dev note (buhtz, 2024-02-08): Test value is low because they depend on
    implementation and are not robust against refactoring the productive code.

    Some observations and suggestions:
     - Rename method UserCallbackPlugin.init()
     - Make UserCallbackPlugin.callback() private
     - UserCallbackPlugin.callback() : Encapsulating the Popen() part would
       improve the mocking.
     - Unit tests about logger output. But migrate "logger" to Python's
       inbuild "logging" module first.
    """
    def _generic_called_with(self, the_step, reason, *args):
        sut = UserCallbackPlugin()
        sut.config = Config()
        sut.script = ''

        mock_name = 'usercallbackplugin.UserCallbackPlugin.callback'
        with mock.patch(mock_name) as func_callback:
            the_step(sut, *args)
            func_callback.assert_called_once()
            func_callback.assert_called_with(reason, *args)

    def test_reason_processBegin(self):
        self._generic_called_with(UserCallbackPlugin.processBegin, '1')

    def test_reason_processEnd(self):
        self._generic_called_with(UserCallbackPlugin.processEnd, '2')

    def test_reason_processnewSnapshot(self):
        self._generic_called_with(UserCallbackPlugin.newSnapshot, '3', 'id1', 'path')

    def test_reason_error(self):
        sut = UserCallbackPlugin()
        sut.config = Config()
        sut.script = ''

        mock_name = 'usercallbackplugin.UserCallbackPlugin.callback'

        # with error message
        with mock.patch(mock_name) as func_callback:
            sut.error('code1', 'message')
            func_callback.assert_called_once()
            func_callback.assert_called_with('4', 'code1', 'message')

        # no error message
        with mock.patch(mock_name) as func_callback:
            sut.error('code2', None)
            func_callback.assert_called_once()
            func_callback.assert_called_with('4', 'code2')

    def test_reason_appStart(self):
        self._generic_called_with(UserCallbackPlugin.appStart, '5')

    def test_reason_appExit(self):
        self._generic_called_with(UserCallbackPlugin.appExit, '6')

    def test_reason_mount(self):
        sut = UserCallbackPlugin()
        sut.config = Config()
        sut.script = ''

        mock_name = 'usercallbackplugin.UserCallbackPlugin.callback'

        # No profileID
        with mock.patch(mock_name) as func_callback:
            sut.mount()
            func_callback.assert_called_once()
            func_callback.assert_called_with('7', profileID=None)

        # With profileID
        with mock.patch(mock_name) as func_callback:
            sut.mount('123')
            func_callback.assert_called_once()
            func_callback.assert_called_with('7', profileID='123')

    def test_reason_unmount(self):
        sut = UserCallbackPlugin()
        sut.config = Config()
        sut.script = ''

        mock_name = 'usercallbackplugin.UserCallbackPlugin.callback'

        # No profileID
        with mock.patch(mock_name) as func_callback:
            sut.unmount()
            func_callback.assert_called_once()
            func_callback.assert_called_with('8', profileID=None)

        # With profileID
        with mock.patch(mock_name) as func_callback:
            sut.unmount('987')
            func_callback.assert_called_once()
            func_callback.assert_called_with('8', profileID='987')


class SystemTest(unittest.TestCase):
    """Full backup run and parsing the log output for the expected
    user-callback returns in correct order.

    Create and use your own config file and take it over via `--config`
    option. Place your own user-callback script in the same folder as
    this config file.
    """

    @classmethod
    def _create_user_callback_file(cls, parent_path):
        content = inspect.cleandoc('''
            #!/usr/bin/env python3
            import sys
            print(sys.argv[1:])
        ''')

        callback_fp = parent_path / 'user-callback'
        callback_fp.write_text(content, 'utf-8')
        callback_fp.chmod(stat.S_IRWXU)

    # Name of folder with files to backup.
    NAME_SOURCE = 'src'
    # Name of folder where snapshots (backups) are stored in.
    NAME_DESTINATION = 'dest'

    @classmethod
    def _extract_callback_responses(cls, output):
        """Extract response of user-callback script out of log output.

        See https://github.com/bit-team/user-callback for documentation about
        user-callback and the response codes.

        Example ::
            # Raw output
            INFO: user-callback returned '['1', 'Main profile', '2']'
            INFO: Something else
            INFO: user-callback returned '['1', 'Main profile', '8']'

            # Result in a two entry list
            [
                ['1', 'Main profile', '2']
                ['1', 'Main profile', '8']
            ]

        Returns:
            A list of response values as lists. First entry is profile, second
            is profile id, third is reason code. If available further entries
            could be contained.
        """

        if isinstance(output, str):
            output = output.splitlines()

        # only log lines related to user-callback
        response_lines = filter(
            lambda line: 'user-callback returned' in line, output)

        callback_responses = []

        for line in response_lines:

            callback_responses.append(
                literal_eval(line[line.index("'")+1:line.rindex("'")])
            )

        # Workaround: Cast profile-id and response-code to integers
        for idx in range(len(callback_responses)):
            callback_responses[idx][0] = int(callback_responses[idx][0])
            callback_responses[idx][2] = int(callback_responses[idx][2])

        return callback_responses

    @classmethod
    def _create_source_and_destination_folders(cls, parent_path):
        # Folder to backup
        src_path = parent_path / cls.NAME_SOURCE
        src_path.mkdir()

        # Files and folders as backup content
        (src_path / 'one').write_bytes(b'0123')
        (src_path / 'subfolder').mkdir()
        (src_path / 'subfolder' / 'two').write_bytes(b'4567')

        # Folder to store backup
        dest_path = parent_path / cls.NAME_DESTINATION
        dest_path.mkdir()

    @classmethod
    def _create_config_file(cls, parent_path):
        """Minimal config file"""
        # pylint: disable-next=R0801
        cfg_content = inspect.cleandoc('''
            config.version=6
            profile1.snapshots.include.1.type=0
            profile1.snapshots.include.1.value={rootpath}/{source}
            profile1.snapshots.include.size=1
            profile1.snapshots.no_on_battery=false
            profile1.snapshots.notify.enabled=true
            profile1.snapshots.path={rootpath}/{destination}
            profile1.snapshots.path.host=test-host
            profile1.snapshots.path.profile=1
            profile1.snapshots.path.user=test-user
            profile1.snapshots.preserve_acl=false
            profile1.snapshots.preserve_xattr=false
            profile1.snapshots.remove_old_snapshots.enabled=true
            profile1.snapshots.remove_old_snapshots.unit=80
            profile1.snapshots.remove_old_snapshots.value=10
            profile1.snapshots.rsync_options.enabled=false
            profile1.snapshots.rsync_options.value=
            profiles.version=1
        ''')

        cfg_content = cfg_content.format(
            rootpath=parent_path,
            source=cls.NAME_SOURCE,
            destination=cls.NAME_DESTINATION
        )

        # config file location
        config_fp = parent_path / 'config_path' / 'config'
        config_fp.parent.mkdir()
        config_fp.write_text(cfg_content, 'utf-8')

        return config_fp

    def setUp(self):
        """Setup a local snapshot profile including a user-callback"""
        # cleanup() happens automatically
        self._temp_dir = tempfile.TemporaryDirectory(prefix='bit.')
        # Workaround: tempfile and pathlib not compatible yet
        self.temp_path = Path(self._temp_dir.name)

        self._create_source_and_destination_folders(self.temp_path)
        self.config_fp = self._create_config_file(self.temp_path)
        self._create_user_callback_file(self.config_fp.parent)

        # Reset this instance because it is not isolated between tests.
        Config.PLUGIN_MANAGER = pluginmanager.PluginManager()

    def test_local_snapshot(self):
        """User-callback response while doing a local snapshot"""

        config = Config(
            config_path=str(self.config_fp),
            data_path=str(self.temp_path / '.local' / 'share')
        )

        full_snapshot_path = config.snapshotsFullPath()
        Path(full_snapshot_path).mkdir(parents=True)

        snapshot = Snapshots(config)

        # DevNote : Because BIT don't use Python's logging module there is
        # no way to use assertLogs(). Current solution is to capture
        # stdout/stderr.
        stdout = io.StringIO()
        stderr = io.StringIO()

        with redirect_stdout(stdout), redirect_stderr(stderr):
            # Result is inverted. 'True' means there was an error.
            self.assertFalse(snapshot.backup())

        # Empty STDOUT output
        self.assertFalse(stdout.getvalue())

        responses = self._extract_callback_responses(stderr.getvalue())

        # Number of responses
        self.assertEqual(5, len(responses))

        # Test Name and ID
        self.assertEqual(
            {(1, 'Main profile')},
            # de-duplicate (using set() )by first two elements in each entry
            {(entry[0], entry[1]) for entry in responses}
        )

        # Order of response codes
        self.assertEqual(
            [
                7,  # Mount
                1,  # Backup begins
                3,  # New snapshot was taken
                2,  # Backup ends
                8,  # Unmount
            ],
            [entry[2] for entry in responses]
        )