1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "fmt"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 "google.golang.org/genproto/googleapis/rpc/errdetails"
15 "google.golang.org/protobuf/types/known/fieldmaskpb"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Every value of the domain maps to a real value of the contract, never to
23// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
24// to the domain and forgotten here would reach clients as "not set".
25func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
26 t.Parallel()
27 t.Run("visibility", func(t *testing.T) {
28 t.Parallel()
29 for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
30 got := visibilityToPB(v)
31 assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
32 back, err := visibilityFromPB(got)
33 require.NoError(t, err, v)
34 assert.Equal(t, v, back)
35 }
36 assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
37 _, err := visibilityFromPB(pb.Visibility(99))
38 assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
39 })
40
41 t.Run("scope", func(t *testing.T) {
42 t.Parallel()
43 for _, s := range domain.AllScopes() {
44 got, ok := scopeToPB[s]
45 require.True(t, ok, "scope %s has no counterpart in the contract", s)
46 assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
47 back, err := scopeFromPB(got)
48 require.NoError(t, err, s)
49 assert.Equal(t, s, back)
50 }
51 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
52 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53 // Every scope of the contract is a scope of the domain.
54 for v := range pb.Scope_name {
55 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
56 continue
57 }
58 _, err := scopeFromPB(pb.Scope(v))
59 assert.NoError(t, err, pb.Scope(v))
60 }
61 })
62
63 t.Run("ref kind", func(t *testing.T) {
64 t.Parallel()
65 for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
66 got := refKindToPB(k)
67 assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
68 back, err := refKindFromPB(got)
69 require.NoError(t, err, k)
70 assert.Equal(t, k, back)
71 }
72 _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
73 assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
74 })
75
76 t.Run("entry kind", func(t *testing.T) {
77 t.Parallel()
78 seen := map[pb.EntryKind]domain.EntryKind{}
79 for _, k := range []domain.EntryKind{
80 domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
81 } {
82 got := entryKindToPB(k)
83 assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
84 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
85 seen[got] = k
86 }
87 })
88
89 t.Run("file status", func(t *testing.T) {
90 t.Parallel()
91 seen := map[pb.FileStatus]domain.FileStatus{}
92 for _, s := range []domain.FileStatus{
93 domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
94 } {
95 got := fileStatusToPB(s)
96 assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
97 assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
98 seen[got] = s
99 }
100 })
101
102 t.Run("line kind", func(t *testing.T) {
103 t.Parallel()
104 seen := map[pb.LineKind]domain.LineKind{}
105 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
106 got := lineKindToPB(k)
107 assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
108 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
109 seen[got] = k
110 }
111 })
112
113 // Every value of the contract except UNSPECIFIED is produced by some value
114 // of the domain: nothing in the contract is unreachable.
115 t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
116 t.Parallel()
117 produced := func(names map[int32]string, from func(v int32) bool) {
118 for v, n := range names {
119 if v == 0 {
120 continue
121 }
122 assert.True(t, from(v), "%s is never produced", n)
123 }
124 }
125 produced(pb.EntryKind_name, func(v int32) bool {
126 for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
127 if entryKindToPB(k) == pb.EntryKind(v) {
128 return true
129 }
130 }
131 return false
132 })
133 produced(pb.FileStatus_name, func(v int32) bool {
134 for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
135 if fileStatusToPB(k) == pb.FileStatus(v) {
136 return true
137 }
138 }
139 return false
140 })
141 produced(pb.LineKind_name, func(v int32) bool {
142 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
143 if lineKindToPB(k) == pb.LineKind(v) {
144 return true
145 }
146 }
147 return false
148 })
149 })
150}
151
152func TestMergeModeMappers(t *testing.T) {
153 t.Parallel()
154 for _, m := range domain.MergeModes() {
155 got := mergeModeToPB(m)
156 assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
157 back, err := mergeModeFromPB(got)
158 require.NoError(t, err, m)
159 assert.Equal(t, m, back)
160 }
161 // Unspecified in a merge request is the default mode, left to the use case;
162 // in the settings it is refused.
163 m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
164 require.NoError(t, err)
165 assert.Empty(t, m)
166 _, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
167 assert.ErrorIs(t, err, domain.ErrInvalid)
168 _, err = mergeModeFromPB(pb.MergeMode(99))
169 assert.ErrorIs(t, err, domain.ErrInvalid)
170 // Every mode of the contract is known.
171 for v := range pb.MergeMode_name {
172 if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
173 _, err := mergeModeFromPB(pb.MergeMode(v))
174 assert.NoError(t, err, pb.MergeMode(v))
175 }
176 }
177}
178
179func TestMergeRequestEnumMappers(t *testing.T) {
180 t.Parallel()
181 for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
182 got := mergeRequestStateToPB(s)
183 assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
184 back, err := mergeRequestStateFromPB(got)
185 require.NoError(t, err, s)
186 assert.Equal(t, s, back)
187 }
188 all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
189 require.NoError(t, err)
190 assert.Empty(t, all, "unspecified means every state")
191 _, err = mergeRequestStateFromPB(99)
192 assert.ErrorIs(t, err, domain.ErrInvalid)
193
194 for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
195 back, err := diffSideFromPB(diffSideToPB(s))
196 require.NoError(t, err, s)
197 assert.Equal(t, s, back)
198 }
199 assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
200 _, err = diffSideFromPB(99)
201 assert.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestCommentID(t *testing.T) {
205 t.Parallel()
206 assert.EqualValues(t, 7, commentID("7"))
207 for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
208 assert.Zero(t, commentID(bad), "%q names no comment", bad)
209 }
210}
211
212func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213 t.Parallel()
214 // A repository value without settings (never stored with them) reads as the defaults.
215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217 assert.False(t, r.GetSettings().GetAllowAutoMerge())
218
219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220 r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222 assert.False(t, r.GetSettings().GetAllowAutoMerge())
223}
224
225func mustName(t *testing.T, s string) domain.RepoName {
226 t.Helper()
227 n, err := domain.ParseRepoName(s)
228 require.NoError(t, err)
229 return n
230}
231
232func TestUpdateParamsSettingsPaths(t *testing.T) {
233 t.Parallel()
234 msg := func(paths ...string) *pb.UpdateRepoRequest {
235 return &pb.UpdateRepoRequest{
236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237 Settings: &pb.RepoSettings{
238 AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239 AllowAutoMerge: true,
240 },
241 }
242 }
243 p, err := updateParams(msg("settings"))
244 require.NoError(t, err)
245 require.NotNil(t, p.AllowedMergeModes)
246 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247 require.NotNil(t, p.AllowAutoMerge)
248 assert.True(t, *p.AllowAutoMerge)
249
250 p, err = updateParams(msg("settings.allowed_merge_modes"))
251 require.NoError(t, err)
252 assert.NotNil(t, p.AllowedMergeModes)
253 assert.Nil(t, p.AllowAutoMerge, "not named: left alone")
254
255 p, err = updateParams(msg("settings.allow_auto_merge"))
256 require.NoError(t, err)
257 assert.Nil(t, p.AllowedMergeModes)
258 assert.NotNil(t, p.AllowAutoMerge)
259
260 // Modes named with nothing to take them from are an empty list, which the use case refuses.
261 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
262 require.NoError(t, err)
263 require.NotNil(t, p.AllowedMergeModes)
264 assert.Empty(t, *p.AllowedMergeModes)
265
266 _, err = updateParams(msg("settings.other"))
267 assert.ErrorIs(t, err, domain.ErrInvalid)
268}
269
270func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
271 t.Parallel()
272 var v domain.Violations
273 v.Add("title", "must be one line")
274 v.Add("source_branch", "does not exist")
275 err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
276 var ce *connect.Error
277 require.ErrorAs(t, err, &ce)
278 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
279 require.Len(t, ce.Details(), 1)
280 val, derr := ce.Details()[0].Value()
281 require.NoError(t, derr)
282 br, ok := val.(*errdetails.BadRequest)
283 require.True(t, ok)
284 require.Len(t, br.GetFieldViolations(), 2)
285 assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
286 assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
287
288 // Other errors carry no detail, and a text error alone is invalid_argument.
289 err = connectError(context.Background(), domain.ErrNotFound)
290 require.ErrorAs(t, err, &ce)
291 assert.Empty(t, ce.Details())
292 _, perr := domain.ParsePlainText("\x07")
293 err = connectError(context.Background(), perr)
294 require.ErrorAs(t, err, &ce)
295 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
296}
297
298func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
299 t.Parallel()
300 repo := mustName(t, "proj")
301 title, err := domain.ParseMRTitle("Add x")
302 require.NoError(t, err)
303 mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
304 at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
305
306 for name, tc := range map[string]struct {
307 ev domain.Event
308 pick func(*pb.Event) *pb.MergeRequest
309 }{
310 "opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
311 "merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
312 "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
313 } {
314 t.Run(name, func(t *testing.T) {
315 t.Parallel()
316 got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev})
317 require.NotNil(t, got)
318 assert.Equal(t, "e1", got.GetId())
319 assert.Equal(t, at, got.GetTime().AsTime())
320 m := tc.pick(got)
321 require.NotNil(t, m)
322 assert.Equal(t, "proj", m.GetRepo())
323 assert.EqualValues(t, 4, m.GetNumber())
324 assert.Equal(t, "Add x", m.GetTitle())
325 assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
326 })
327 }
328
329 c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
330 got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
331 added := got.GetCommentAdded()
332 require.NotNil(t, added)
333 assert.Equal(t, "proj", added.GetRepo())
334 assert.EqualValues(t, 4, added.GetNumber())
335 assert.Equal(t, "9", added.GetComment().GetId())
336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338}
339
340func TestIssueMappers(t *testing.T) {
341 t.Parallel()
342 for _, m := range domain.IssuesModes() {
343 got, err := issuesModeFromPB(issuesModeToPB(m))
344 require.NoError(t, err)
345 assert.Equal(t, m, got)
346 }
347 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348 _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349 assert.ErrorIs(t, err, domain.ErrInvalid)
350
351 s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352 require.NoError(t, err)
353 assert.Empty(t, s)
354 _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355 assert.ErrorIs(t, err, domain.ErrInvalid)
356
357 n, err := domain.ParseRepoName("proj")
358 require.NoError(t, err)
359 ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360 require.NotNil(t, ev.GetIssueClosed())
361 assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363 ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364 assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365 assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366 assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367}
368
369func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370 t.Parallel()
371 assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372}
373
374func TestIssueStateToPBNeverPending(t *testing.T) {
375 t.Parallel()
376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378}
379
380func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381 t.Parallel()
382 repo := mustName(t, "proj")
383 for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384 mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385 got := mergeRequestToPB(mr).GetMergeability()
386 if !want {
387 assert.Nil(t, got, state)
388 continue
389 }
390 require.NotNil(t, got)
391 assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392 ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393 assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394 }
395 for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396 got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397 assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399 }
400}
401
402func TestFileDiffOmissionMapping(t *testing.T) {
403 for o, want := range map[domain.FileOmission]pb.FileOmission{
404 domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405 domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED,
406 domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE,
407 domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET,
408 domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409 } {
410 f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411 assert.Equal(t, want, f.GetOmitted())
412 assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413 d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414 assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415 }
416 assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417 assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418}
419
420func TestResolvedIssueContract(t *testing.T) {
421 title, err := domain.ParseMRTitle("Fix")
422 require.NoError(t, err)
423 for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
424 back, err := issueStateFromPB(issueStateToPB(state))
425 require.NoError(t, err)
426 assert.Equal(t, state, back)
427 assert.NotEqual(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED, mergeRequestIssueToPB(ports.MergeRequestIssue{State: state}).GetState())
428 }
429 for kind, want := range map[domain.IssueEventKind]pb.IssueEventKind{
430 domain.IssueEventReferenced: pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED,
431 domain.IssueEventClosed: pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED,
432 domain.IssueEventReopened: pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED,
433 domain.IssueEventResolved: pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED,
434 } {
435 at := time.Now().UTC()
436 got := issueEventToPB(ports.IssueEventDetail{Event: domain.IssueEvent{ID: 3, Kind: kind, Issue: 4, MergeRequest: 5, Closes: true, Actor: "merger", CreatedAt: at}, MergeRequestTitle: title})
437 assert.Equal(t, want, got.GetKind())
438 assert.Equal(t, "3", got.GetId())
439 assert.EqualValues(t, 4, got.GetIssue())
440 assert.EqualValues(t, 5, got.GetMergeRequest())
441 assert.True(t, got.GetCloses())
442 assert.Equal(t, "merger", got.GetActor())
443 assert.Equal(t, "Fix", got.GetMergeRequestTitle())
444 assert.Equal(t, at, got.GetCreatedAt().AsTime())
445 }
446 ev := watchEventToPB(ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Number: 4, State: domain.IssueStateResolved}}})
447 require.NotNil(t, ev.GetIssueResolved())
448 assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, ev.GetIssueResolved().GetIssue().GetState())
449}