velocity-python 0.0.131__py3-none-any.whl → 0.0.134__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of velocity-python might be problematic. Click here for more details.
- velocity/__init__.py +1 -1
- velocity/app/tests/__init__.py +1 -0
- velocity/app/tests/test_email_processing.py +112 -0
- velocity/app/tests/test_payment_profile_sorting.py +191 -0
- velocity/app/tests/test_spreadsheet_functions.py +124 -0
- velocity/aws/tests/__init__.py +1 -0
- velocity/aws/tests/test_lambda_handler_json_serialization.py +120 -0
- velocity/aws/tests/test_response.py +163 -0
- velocity/db/core/decorators.py +20 -3
- velocity/db/core/engine.py +33 -7
- velocity/db/exceptions.py +7 -0
- velocity/db/servers/base/__init__.py +9 -0
- velocity/db/servers/base/initializer.py +70 -0
- velocity/db/servers/base/operators.py +98 -0
- velocity/db/servers/base/sql.py +503 -0
- velocity/db/servers/base/types.py +135 -0
- velocity/db/servers/mysql/__init__.py +73 -0
- velocity/db/servers/mysql/operators.py +54 -0
- velocity/db/servers/{mysql_reserved.py → mysql/reserved.py} +2 -14
- velocity/db/servers/mysql/sql.py +569 -0
- velocity/db/servers/mysql/types.py +107 -0
- velocity/db/servers/postgres/__init__.py +52 -2
- velocity/db/servers/postgres/operators.py +34 -0
- velocity/db/servers/postgres/sql.py +4 -3
- velocity/db/servers/postgres/types.py +88 -2
- velocity/db/servers/sqlite/__init__.py +61 -0
- velocity/db/servers/sqlite/operators.py +52 -0
- velocity/db/servers/sqlite/reserved.py +20 -0
- velocity/db/servers/sqlite/sql.py +530 -0
- velocity/db/servers/sqlite/types.py +92 -0
- velocity/db/servers/sqlserver/__init__.py +73 -0
- velocity/db/servers/sqlserver/operators.py +47 -0
- velocity/db/servers/sqlserver/reserved.py +32 -0
- velocity/db/servers/sqlserver/sql.py +625 -0
- velocity/db/servers/sqlserver/types.py +114 -0
- velocity/db/tests/__init__.py +1 -0
- velocity/db/tests/common_db_test.py +0 -0
- velocity/db/tests/postgres/__init__.py +1 -0
- velocity/db/tests/postgres/common.py +49 -0
- velocity/db/tests/postgres/test_column.py +29 -0
- velocity/db/tests/postgres/test_connections.py +25 -0
- velocity/db/tests/postgres/test_database.py +21 -0
- velocity/db/tests/postgres/test_engine.py +205 -0
- velocity/db/tests/postgres/test_general_usage.py +88 -0
- velocity/db/tests/postgres/test_imports.py +8 -0
- velocity/db/tests/postgres/test_result.py +19 -0
- velocity/db/tests/postgres/test_row.py +137 -0
- velocity/db/tests/postgres/test_schema_locking.py +335 -0
- velocity/db/tests/postgres/test_schema_locking_unit.py +115 -0
- velocity/db/tests/postgres/test_sequence.py +34 -0
- velocity/db/tests/postgres/test_table.py +101 -0
- velocity/db/tests/postgres/test_transaction.py +106 -0
- velocity/db/tests/sql/__init__.py +1 -0
- velocity/db/tests/sql/common.py +177 -0
- velocity/db/tests/sql/test_postgres_select_advanced.py +285 -0
- velocity/db/tests/sql/test_postgres_select_variances.py +517 -0
- velocity/db/tests/test_cursor_rowcount_fix.py +150 -0
- velocity/db/tests/test_db_utils.py +221 -0
- velocity/db/tests/test_postgres.py +212 -0
- velocity/db/tests/test_postgres_unchanged.py +81 -0
- velocity/db/tests/test_process_error_robustness.py +292 -0
- velocity/db/tests/test_result_caching.py +279 -0
- velocity/db/tests/test_result_sql_aware.py +117 -0
- velocity/db/tests/test_row_get_missing_column.py +72 -0
- velocity/db/tests/test_schema_locking_initializers.py +226 -0
- velocity/db/tests/test_schema_locking_simple.py +97 -0
- velocity/db/tests/test_sql_builder.py +165 -0
- velocity/db/tests/test_tablehelper.py +486 -0
- velocity/misc/tests/__init__.py +1 -0
- velocity/misc/tests/test_db.py +90 -0
- velocity/misc/tests/test_fix.py +78 -0
- velocity/misc/tests/test_format.py +64 -0
- velocity/misc/tests/test_iconv.py +203 -0
- velocity/misc/tests/test_merge.py +82 -0
- velocity/misc/tests/test_oconv.py +144 -0
- velocity/misc/tests/test_original_error.py +52 -0
- velocity/misc/tests/test_timer.py +74 -0
- {velocity_python-0.0.131.dist-info → velocity_python-0.0.134.dist-info}/METADATA +1 -1
- velocity_python-0.0.134.dist-info/RECORD +125 -0
- velocity/db/servers/mysql.py +0 -640
- velocity/db/servers/sqlite.py +0 -968
- velocity/db/servers/sqlite_reserved.py +0 -208
- velocity/db/servers/sqlserver.py +0 -921
- velocity/db/servers/sqlserver_reserved.py +0 -314
- velocity_python-0.0.131.dist-info/RECORD +0 -62
- {velocity_python-0.0.131.dist-info → velocity_python-0.0.134.dist-info}/WHEEL +0 -0
- {velocity_python-0.0.131.dist-info → velocity_python-0.0.134.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.0.131.dist-info → velocity_python-0.0.134.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# App module tests
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from email.mime.text import MIMEText
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from email.mime.application import MIMEApplication
|
|
5
|
+
from velocity.misc.mail import (
|
|
6
|
+
Attachment,
|
|
7
|
+
get_full_emails,
|
|
8
|
+
get_address_only,
|
|
9
|
+
parse_attachment,
|
|
10
|
+
parse,
|
|
11
|
+
)
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestEmailProcessing(unittest.TestCase):
|
|
16
|
+
|
|
17
|
+
def test_get_full_emails(self):
|
|
18
|
+
"""Test that email addresses with names are formatted correctly."""
|
|
19
|
+
mock_address = [
|
|
20
|
+
type(
|
|
21
|
+
"Address",
|
|
22
|
+
(),
|
|
23
|
+
{"mailbox": b"john", "host": b"example.com", "name": b"John Doe"},
|
|
24
|
+
),
|
|
25
|
+
type(
|
|
26
|
+
"Address",
|
|
27
|
+
(),
|
|
28
|
+
{"mailbox": b"jane", "host": b"example.com", "name": None},
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
result = get_full_emails(mock_address)
|
|
32
|
+
self.assertEqual(result, ["John Doe <john@example.com>", "jane@example.com"])
|
|
33
|
+
|
|
34
|
+
def test_get_address_only(self):
|
|
35
|
+
"""Test that only email addresses without names are returned."""
|
|
36
|
+
mock_address = [
|
|
37
|
+
type("Address", (), {"mailbox": b"john", "host": b"example.com"}),
|
|
38
|
+
type("Address", (), {"mailbox": b"jane", "host": b"example.com"}),
|
|
39
|
+
]
|
|
40
|
+
result = get_address_only(mock_address)
|
|
41
|
+
self.assertEqual(result, ["john@example.com", "jane@example.com"])
|
|
42
|
+
|
|
43
|
+
def test_attachment_initialization(self):
|
|
44
|
+
"""Test the initialization of the Attachment class."""
|
|
45
|
+
data = b"file data"
|
|
46
|
+
attachment = Attachment(name="file.txt", data=data)
|
|
47
|
+
self.assertEqual(attachment.name, "file.txt")
|
|
48
|
+
self.assertEqual(attachment.data, data)
|
|
49
|
+
self.assertEqual(attachment.size, len(data))
|
|
50
|
+
self.assertEqual(attachment.ctype, "text/plain")
|
|
51
|
+
self.assertEqual(attachment.hash, hashlib.sha1(data).hexdigest())
|
|
52
|
+
|
|
53
|
+
def test_parse_attachment(self):
|
|
54
|
+
"""Test parsing a valid attachment from a message part."""
|
|
55
|
+
part = MIMEApplication(b"Test file data", Name="test.txt")
|
|
56
|
+
part["Content-Disposition"] = 'attachment; filename="test.txt"'
|
|
57
|
+
attachment = parse_attachment(part)
|
|
58
|
+
self.assertIsInstance(attachment, Attachment)
|
|
59
|
+
self.assertEqual(attachment.name, "test.txt")
|
|
60
|
+
self.assertEqual(attachment.ctype, "text/plain")
|
|
61
|
+
self.assertEqual(attachment.data, b"Test file data")
|
|
62
|
+
|
|
63
|
+
def test_parse_attachment_none(self):
|
|
64
|
+
"""Test that parse_attachment returns None if there's no attachment."""
|
|
65
|
+
part = MIMEText("This is a plain text email part")
|
|
66
|
+
self.assertIsNone(parse_attachment(part))
|
|
67
|
+
|
|
68
|
+
def test_parse_plain_text_email(self):
|
|
69
|
+
"""Test parsing a plain text email."""
|
|
70
|
+
msg = MIMEText("This is a plain text email")
|
|
71
|
+
msg["Content-Type"] = "text/plain"
|
|
72
|
+
result = parse(msg.as_string())
|
|
73
|
+
self.assertEqual(result["body"], "This is a plain text email")
|
|
74
|
+
self.assertIsNone(result["html"])
|
|
75
|
+
self.assertEqual(result["attachments"], [])
|
|
76
|
+
|
|
77
|
+
def test_parse_html_email(self):
|
|
78
|
+
"""Test parsing an HTML email."""
|
|
79
|
+
msg = MIMEText("<p>This is an HTML email</p>", "html")
|
|
80
|
+
msg["Content-Type"] = "text/html"
|
|
81
|
+
result = parse(msg.as_string())
|
|
82
|
+
self.assertEqual(result["html"], "<p>This is an HTML email</p>")
|
|
83
|
+
self.assertIsNone(result["body"])
|
|
84
|
+
self.assertEqual(result["attachments"], [])
|
|
85
|
+
|
|
86
|
+
def test_parse_multipart_email_with_attachments(self):
|
|
87
|
+
"""Test parsing a multipart email with attachments."""
|
|
88
|
+
msg = MIMEMultipart()
|
|
89
|
+
msg.attach(MIMEText("This is a plain text part", "plain"))
|
|
90
|
+
msg.attach(MIMEText("<p>This is an HTML part</p>", "html"))
|
|
91
|
+
|
|
92
|
+
attachment_part = MIMEApplication(b"Attachment data", Name="attachment.txt")
|
|
93
|
+
attachment_part["Content-Disposition"] = 'attachment; filename="attachment.txt"'
|
|
94
|
+
msg.attach(attachment_part)
|
|
95
|
+
|
|
96
|
+
result = parse(msg.as_string())
|
|
97
|
+
self.assertEqual(result["body"], "This is a plain text part")
|
|
98
|
+
self.assertEqual(result["html"], "<p>This is an HTML part</p>")
|
|
99
|
+
self.assertEqual(len(result["attachments"]), 1)
|
|
100
|
+
self.assertEqual(result["attachments"][0].name, "attachment.txt")
|
|
101
|
+
self.assertEqual(result["attachments"][0].data, b"Attachment data")
|
|
102
|
+
|
|
103
|
+
def test_parse_empty_email(self):
|
|
104
|
+
"""Test parsing an empty email string."""
|
|
105
|
+
result = parse("")
|
|
106
|
+
self.assertIsNone(result["body"])
|
|
107
|
+
self.assertIsNone(result["html"])
|
|
108
|
+
self.assertEqual(result["attachments"], [])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Test for payment profile sorting with None expiration dates.
|
|
4
|
+
|
|
5
|
+
This addresses the production error:
|
|
6
|
+
TypeError: '<' not supported between instances of 'NoneType' and 'NoneType'
|
|
7
|
+
|
|
8
|
+
The error occurs when sorting payment profiles by expiration_date when some values are None.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import unittest
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestPaymentProfileSorting(unittest.TestCase):
|
|
16
|
+
"""Test sorting payment profiles with various expiration date scenarios."""
|
|
17
|
+
|
|
18
|
+
def setUp(self):
|
|
19
|
+
"""Set up test data with various expiration date scenarios."""
|
|
20
|
+
self.payment_profiles = [
|
|
21
|
+
{
|
|
22
|
+
"sys_id": 1,
|
|
23
|
+
"email_address": "test1@example.com",
|
|
24
|
+
"card_number": "1234",
|
|
25
|
+
"expiration_date": "2024-12",
|
|
26
|
+
"status": "active",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"sys_id": 2,
|
|
30
|
+
"email_address": "test1@example.com",
|
|
31
|
+
"card_number": "1234",
|
|
32
|
+
"expiration_date": "2024-06",
|
|
33
|
+
"status": "active",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"sys_id": 3,
|
|
37
|
+
"email_address": "test1@example.com",
|
|
38
|
+
"card_number": "1234",
|
|
39
|
+
"expiration_date": None,
|
|
40
|
+
"status": "active",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"sys_id": 4,
|
|
44
|
+
"email_address": "test1@example.com",
|
|
45
|
+
"card_number": "1234",
|
|
46
|
+
"expiration_date": "2025-01",
|
|
47
|
+
"status": "active",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"sys_id": 5,
|
|
51
|
+
"email_address": "test1@example.com",
|
|
52
|
+
"card_number": "1234",
|
|
53
|
+
"expiration_date": None,
|
|
54
|
+
"status": "active",
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def test_original_error_reproduction(self):
|
|
59
|
+
"""Reproduce the original error to confirm it happens."""
|
|
60
|
+
with self.assertRaises(TypeError) as context:
|
|
61
|
+
# This should fail with the original error
|
|
62
|
+
sorted(self.payment_profiles, key=lambda x: x["expiration_date"])
|
|
63
|
+
|
|
64
|
+
self.assertIn(
|
|
65
|
+
"'<' not supported between instances of 'NoneType'", str(context.exception)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def test_safe_sorting_with_none_last(self):
|
|
69
|
+
"""Test sorting with None values placed at the end."""
|
|
70
|
+
|
|
71
|
+
def safe_sort_key_none_last(row):
|
|
72
|
+
"""Sort key that places None values at the end."""
|
|
73
|
+
exp_date = row["expiration_date"]
|
|
74
|
+
if exp_date is None:
|
|
75
|
+
return (1, "") # (1, "") sorts after (0, any_date)
|
|
76
|
+
return (0, exp_date)
|
|
77
|
+
|
|
78
|
+
sorted_profiles = sorted(self.payment_profiles, key=safe_sort_key_none_last)
|
|
79
|
+
|
|
80
|
+
# Check that non-None dates come first and are in order
|
|
81
|
+
non_none_dates = [
|
|
82
|
+
p["expiration_date"]
|
|
83
|
+
for p in sorted_profiles
|
|
84
|
+
if p["expiration_date"] is not None
|
|
85
|
+
]
|
|
86
|
+
self.assertEqual(non_none_dates, ["2024-06", "2024-12", "2025-01"])
|
|
87
|
+
|
|
88
|
+
# Check that None values come last
|
|
89
|
+
none_dates = [
|
|
90
|
+
p["expiration_date"]
|
|
91
|
+
for p in sorted_profiles
|
|
92
|
+
if p["expiration_date"] is None
|
|
93
|
+
]
|
|
94
|
+
self.assertEqual(len(none_dates), 2)
|
|
95
|
+
|
|
96
|
+
# Verify the full sort order
|
|
97
|
+
expected_order = ["2024-06", "2024-12", "2025-01", None, None]
|
|
98
|
+
actual_order = [p["expiration_date"] for p in sorted_profiles]
|
|
99
|
+
self.assertEqual(actual_order, expected_order)
|
|
100
|
+
|
|
101
|
+
def test_safe_sorting_with_none_first(self):
|
|
102
|
+
"""Test sorting with None values placed at the beginning."""
|
|
103
|
+
|
|
104
|
+
def safe_sort_key_none_first(row):
|
|
105
|
+
"""Sort key that places None values at the beginning."""
|
|
106
|
+
exp_date = row["expiration_date"]
|
|
107
|
+
if exp_date is None:
|
|
108
|
+
return (0, "") # (0, "") sorts before (1, any_date)
|
|
109
|
+
return (1, exp_date)
|
|
110
|
+
|
|
111
|
+
sorted_profiles = sorted(self.payment_profiles, key=safe_sort_key_none_first)
|
|
112
|
+
|
|
113
|
+
# Check that None values come first
|
|
114
|
+
none_count = sum(1 for p in sorted_profiles[:2] if p["expiration_date"] is None)
|
|
115
|
+
self.assertEqual(none_count, 2)
|
|
116
|
+
|
|
117
|
+
# Check that non-None dates come after and are in order
|
|
118
|
+
non_none_dates = [
|
|
119
|
+
p["expiration_date"]
|
|
120
|
+
for p in sorted_profiles
|
|
121
|
+
if p["expiration_date"] is not None
|
|
122
|
+
]
|
|
123
|
+
self.assertEqual(non_none_dates, ["2024-06", "2024-12", "2025-01"])
|
|
124
|
+
|
|
125
|
+
def test_safe_sorting_with_default_date(self):
|
|
126
|
+
"""Test sorting with None values replaced by a default date."""
|
|
127
|
+
|
|
128
|
+
def safe_sort_key_with_default(row):
|
|
129
|
+
"""Sort key that replaces None with a default date."""
|
|
130
|
+
exp_date = row["expiration_date"]
|
|
131
|
+
if exp_date is None:
|
|
132
|
+
return "1900-01" # Very old date so None values sort first
|
|
133
|
+
return exp_date
|
|
134
|
+
|
|
135
|
+
sorted_profiles = sorted(self.payment_profiles, key=safe_sort_key_with_default)
|
|
136
|
+
|
|
137
|
+
# Check that None values (now "1900-01") come first
|
|
138
|
+
none_count = sum(1 for p in sorted_profiles[:2] if p["expiration_date"] is None)
|
|
139
|
+
self.assertEqual(none_count, 2)
|
|
140
|
+
|
|
141
|
+
# Check the overall order
|
|
142
|
+
actual_order = [p["expiration_date"] for p in sorted_profiles]
|
|
143
|
+
expected_order = [None, None, "2024-06", "2024-12", "2025-01"]
|
|
144
|
+
self.assertEqual(actual_order, expected_order)
|
|
145
|
+
|
|
146
|
+
def test_grouped_sorting_scenario(self):
|
|
147
|
+
"""Test the specific scenario from the billing handler."""
|
|
148
|
+
# Group profiles by email and card number (as in the original code)
|
|
149
|
+
groups = {}
|
|
150
|
+
for row in self.payment_profiles:
|
|
151
|
+
key = (row["email_address"], row["card_number"])
|
|
152
|
+
if key not in groups:
|
|
153
|
+
groups[key] = []
|
|
154
|
+
groups[key].append(row)
|
|
155
|
+
|
|
156
|
+
# Process each group with safe sorting
|
|
157
|
+
for group in groups.values():
|
|
158
|
+
# This should not raise an error
|
|
159
|
+
def safe_sort_key(row):
|
|
160
|
+
exp_date = row["expiration_date"]
|
|
161
|
+
return (exp_date is None, exp_date or "")
|
|
162
|
+
|
|
163
|
+
sorted_group = sorted(group, key=safe_sort_key)
|
|
164
|
+
|
|
165
|
+
# Verify we can enumerate through the sorted group
|
|
166
|
+
for idx, row in enumerate(sorted_group):
|
|
167
|
+
self.assertIsInstance(idx, int)
|
|
168
|
+
self.assertIn("sys_id", row)
|
|
169
|
+
self.assertIn("expiration_date", row)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def safe_expiration_date_sort_key(row):
|
|
173
|
+
"""
|
|
174
|
+
Safe sorting key for payment profiles by expiration date.
|
|
175
|
+
|
|
176
|
+
Handles None values by placing them at the end of the sort order.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
row: Dictionary representing a payment profile with 'expiration_date' key
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple that can be safely sorted, with None values last
|
|
183
|
+
"""
|
|
184
|
+
exp_date = row["expiration_date"]
|
|
185
|
+
if exp_date is None:
|
|
186
|
+
return (1, "") # (1, "") sorts after (0, any_date)
|
|
187
|
+
return (0, exp_date)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
unittest.main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import base64
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from openpyxl import load_workbook
|
|
5
|
+
from velocity.misc.export import (
|
|
6
|
+
extract,
|
|
7
|
+
autosize_columns,
|
|
8
|
+
create_spreadsheet,
|
|
9
|
+
get_downloadable_spreadsheet,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestSpreadsheetFunctions(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_extract(self):
|
|
16
|
+
"""Test extracting values from a dictionary based on a list of keys."""
|
|
17
|
+
data = {"name": "Alice", "age": 30, "city": "Wonderland"}
|
|
18
|
+
keys = ["name", "city", "nonexistent_key"]
|
|
19
|
+
result = extract(data, keys)
|
|
20
|
+
self.assertEqual(result, ["Alice", "Wonderland", None])
|
|
21
|
+
|
|
22
|
+
def test_autosize_columns(self):
|
|
23
|
+
"""Test that the columns are autosized based on the content."""
|
|
24
|
+
buffer = BytesIO()
|
|
25
|
+
headers = ["Column1", "Column2"]
|
|
26
|
+
rows = [["Short", "This is a longer text that should set the column width"]]
|
|
27
|
+
|
|
28
|
+
# Create a workbook and worksheet
|
|
29
|
+
create_spreadsheet(headers, rows, buffer)
|
|
30
|
+
buffer.seek(0)
|
|
31
|
+
workbook = load_workbook(buffer)
|
|
32
|
+
worksheet = workbook.active
|
|
33
|
+
|
|
34
|
+
# Run autosize_columns on the loaded worksheet
|
|
35
|
+
autosize_columns(worksheet)
|
|
36
|
+
|
|
37
|
+
# Check if the column widths were adjusted properly
|
|
38
|
+
col1_width = worksheet.column_dimensions["A"].width
|
|
39
|
+
col2_width = worksheet.column_dimensions["B"].width
|
|
40
|
+
self.assertGreater(col2_width, col1_width)
|
|
41
|
+
|
|
42
|
+
def test_create_spreadsheet_basic(self):
|
|
43
|
+
"""Test creating a spreadsheet with headers and rows."""
|
|
44
|
+
buffer = BytesIO()
|
|
45
|
+
headers = ["Header1", "Header2"]
|
|
46
|
+
rows = [["Row1-Col1", "Row1-Col2"], ["Row2-Col1", "Row2-Col2"]]
|
|
47
|
+
|
|
48
|
+
create_spreadsheet(headers, rows, buffer)
|
|
49
|
+
buffer.seek(0)
|
|
50
|
+
workbook = load_workbook(buffer)
|
|
51
|
+
worksheet = workbook.active
|
|
52
|
+
|
|
53
|
+
# Verify headers and rows
|
|
54
|
+
self.assertEqual(worksheet["A1"].value, "Header1")
|
|
55
|
+
self.assertEqual(worksheet["B1"].value, "Header2")
|
|
56
|
+
self.assertEqual(worksheet["A2"].value, "Row1-Col1")
|
|
57
|
+
self.assertEqual(worksheet["B2"].value, "Row1-Col2")
|
|
58
|
+
self.assertEqual(worksheet["A3"].value, "Row2-Col1")
|
|
59
|
+
self.assertEqual(worksheet["B3"].value, "Row2-Col2")
|
|
60
|
+
|
|
61
|
+
def test_create_spreadsheet_with_styles_and_merge(self):
|
|
62
|
+
"""Test creating a spreadsheet with custom styles and merged cells."""
|
|
63
|
+
buffer = BytesIO()
|
|
64
|
+
headers = ["Header1", "Header2"]
|
|
65
|
+
rows = [["Row1-Col1", "Row1-Col2"]]
|
|
66
|
+
styles = {"A1": "col_header", "B1": "col_header"}
|
|
67
|
+
merge = ["A1:B1"]
|
|
68
|
+
|
|
69
|
+
create_spreadsheet(headers, rows, buffer, styles=styles, merge=merge)
|
|
70
|
+
buffer.seek(0)
|
|
71
|
+
workbook = load_workbook(buffer)
|
|
72
|
+
worksheet = workbook.active
|
|
73
|
+
|
|
74
|
+
# Verify merged cells and styles
|
|
75
|
+
self.assertTrue(worksheet.merged_cells.ranges)
|
|
76
|
+
self.assertEqual(str(worksheet.merged_cells.ranges[0]), "A1:B1")
|
|
77
|
+
self.assertEqual(worksheet["A1"].style, "col_header")
|
|
78
|
+
self.assertEqual(worksheet["B1"].style, "col_header")
|
|
79
|
+
|
|
80
|
+
def test_create_spreadsheet_with_freeze_panes_and_dimensions(self):
|
|
81
|
+
"""Test creating a spreadsheet with freeze panes and custom row/column dimensions."""
|
|
82
|
+
buffer = BytesIO()
|
|
83
|
+
headers = ["Header1", "Header2"]
|
|
84
|
+
rows = [["Row1-Col1", "Row1-Col2"]]
|
|
85
|
+
dimensions = {"rows": {1: 25}, "columns": {"A": 20, "B": 30}}
|
|
86
|
+
|
|
87
|
+
create_spreadsheet(
|
|
88
|
+
headers, rows, buffer, freeze_panes="A2", dimensions=dimensions
|
|
89
|
+
)
|
|
90
|
+
buffer.seek(0)
|
|
91
|
+
workbook = load_workbook(buffer)
|
|
92
|
+
worksheet = workbook.active
|
|
93
|
+
|
|
94
|
+
# Verify freeze panes and custom dimensions
|
|
95
|
+
self.assertEqual(worksheet.freeze_panes, "A2")
|
|
96
|
+
self.assertEqual(worksheet.row_dimensions[1].height, 25)
|
|
97
|
+
self.assertEqual(worksheet.column_dimensions["A"].width, 20)
|
|
98
|
+
self.assertEqual(worksheet.column_dimensions["B"].width, 30)
|
|
99
|
+
|
|
100
|
+
def test_get_downloadable_spreadsheet(self):
|
|
101
|
+
"""Test generating a downloadable spreadsheet encoded in base64."""
|
|
102
|
+
headers = ["Header1", "Header2"]
|
|
103
|
+
rows = [["Row1-Col1", "Row1-Col2"], ["Row2-Col1", "Row2-Col2"]]
|
|
104
|
+
|
|
105
|
+
# Generate the base64-encoded spreadsheet
|
|
106
|
+
encoded_spreadsheet = get_downloadable_spreadsheet(headers, rows)
|
|
107
|
+
decoded_data = base64.b64decode(encoded_spreadsheet)
|
|
108
|
+
|
|
109
|
+
# Load the spreadsheet from the decoded data and verify its content
|
|
110
|
+
buffer = BytesIO(decoded_data)
|
|
111
|
+
workbook = load_workbook(buffer)
|
|
112
|
+
worksheet = workbook.active
|
|
113
|
+
|
|
114
|
+
# Verify headers and rows
|
|
115
|
+
self.assertEqual(worksheet["A1"].value, "Header1")
|
|
116
|
+
self.assertEqual(worksheet["B1"].value, "Header2")
|
|
117
|
+
self.assertEqual(worksheet["A2"].value, "Row1-Col1")
|
|
118
|
+
self.assertEqual(worksheet["B2"].value, "Row1-Col2")
|
|
119
|
+
self.assertEqual(worksheet["A3"].value, "Row2-Col1")
|
|
120
|
+
self.assertEqual(worksheet["B3"].value, "Row2-Col2")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# AWS module tests
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test Lambda Handler JSON Serialization Fix
|
|
3
|
+
|
|
4
|
+
This test verifies that LambdaHandler.serve() returns a JSON-serializable
|
|
5
|
+
dictionary instead of a Response object, preventing the
|
|
6
|
+
"Object of type method is not JSON serializable" error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
# Add src to path for testing
|
|
16
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
17
|
+
|
|
18
|
+
from velocity.aws.handlers.lambda_handler import LambdaHandler
|
|
19
|
+
from velocity.aws.handlers.response import Response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestLambdaHandlerJSONSerialization(unittest.TestCase):
|
|
23
|
+
"""Test cases for Lambda Handler JSON serialization."""
|
|
24
|
+
|
|
25
|
+
def setUp(self):
|
|
26
|
+
"""Set up test fixtures."""
|
|
27
|
+
self.test_event = {
|
|
28
|
+
"body": '{"action": "test", "payload": {}}',
|
|
29
|
+
"httpMethod": "POST",
|
|
30
|
+
"headers": {"Content-Type": "application/json"},
|
|
31
|
+
"requestContext": {
|
|
32
|
+
"identity": {
|
|
33
|
+
"sourceIp": "127.0.0.1", # localhost test IP for unit testing
|
|
34
|
+
"userAgent": "test-agent"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"queryStringParameters": {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
self.test_context = MagicMock()
|
|
41
|
+
self.test_context.function_name = "test-function"
|
|
42
|
+
|
|
43
|
+
def test_serve_returns_json_serializable_dict(self):
|
|
44
|
+
"""Test that serve() returns a JSON-serializable dictionary."""
|
|
45
|
+
|
|
46
|
+
# Create handler
|
|
47
|
+
handler = LambdaHandler(self.test_event, self.test_context)
|
|
48
|
+
|
|
49
|
+
# Mock the transaction decorator to pass through tx
|
|
50
|
+
with patch('velocity.aws.handlers.lambda_handler.engine') as mock_engine:
|
|
51
|
+
def mock_transaction(func):
|
|
52
|
+
def wrapper(*args, **kwargs):
|
|
53
|
+
mock_tx = MagicMock()
|
|
54
|
+
return func(mock_tx, *args, **kwargs)
|
|
55
|
+
return wrapper
|
|
56
|
+
|
|
57
|
+
mock_engine.transaction = mock_transaction
|
|
58
|
+
|
|
59
|
+
# Call serve method
|
|
60
|
+
result = handler.serve(MagicMock())
|
|
61
|
+
|
|
62
|
+
# Verify result is a dictionary (JSON-serializable)
|
|
63
|
+
self.assertIsInstance(result, dict)
|
|
64
|
+
|
|
65
|
+
# Verify it has the expected Lambda response structure
|
|
66
|
+
self.assertIn("statusCode", result)
|
|
67
|
+
self.assertIn("headers", result)
|
|
68
|
+
self.assertIn("body", result)
|
|
69
|
+
|
|
70
|
+
# Verify the body is a JSON string
|
|
71
|
+
self.assertIsInstance(result["body"], str)
|
|
72
|
+
|
|
73
|
+
# Verify the entire result can be JSON serialized
|
|
74
|
+
try:
|
|
75
|
+
json.dumps(result)
|
|
76
|
+
except (TypeError, ValueError) as e:
|
|
77
|
+
self.fail(f"Result is not JSON serializable: {e}")
|
|
78
|
+
|
|
79
|
+
def test_response_object_has_render_method(self):
|
|
80
|
+
"""Test that Response object has a proper render method."""
|
|
81
|
+
response = Response()
|
|
82
|
+
|
|
83
|
+
# Verify render method exists
|
|
84
|
+
self.assertTrue(hasattr(response, 'render'))
|
|
85
|
+
self.assertTrue(callable(response.render))
|
|
86
|
+
|
|
87
|
+
# Verify render returns a dictionary
|
|
88
|
+
rendered = response.render()
|
|
89
|
+
self.assertIsInstance(rendered, dict)
|
|
90
|
+
|
|
91
|
+
# Verify structure
|
|
92
|
+
self.assertIn("statusCode", rendered)
|
|
93
|
+
self.assertIn("headers", rendered)
|
|
94
|
+
self.assertIn("body", rendered)
|
|
95
|
+
|
|
96
|
+
# Verify JSON serializable
|
|
97
|
+
try:
|
|
98
|
+
json.dumps(rendered)
|
|
99
|
+
except (TypeError, ValueError) as e:
|
|
100
|
+
self.fail(f"Rendered response is not JSON serializable: {e}")
|
|
101
|
+
|
|
102
|
+
def test_response_render_vs_raw_object(self):
|
|
103
|
+
"""Test the difference between Response object and rendered response."""
|
|
104
|
+
response = Response()
|
|
105
|
+
|
|
106
|
+
# Raw response object should not be directly JSON serializable
|
|
107
|
+
# (it contains method references)
|
|
108
|
+
with self.assertRaises((TypeError, ValueError)):
|
|
109
|
+
json.dumps(response)
|
|
110
|
+
|
|
111
|
+
# But rendered response should be JSON serializable
|
|
112
|
+
rendered = response.render()
|
|
113
|
+
try:
|
|
114
|
+
json.dumps(rendered)
|
|
115
|
+
except (TypeError, ValueError) as e:
|
|
116
|
+
self.fail(f"Rendered response should be JSON serializable: {e}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == '__main__':
|
|
120
|
+
unittest.main()
|